Meny

This page exists in one language only. Some pages here are English, some Swedish.

Security & Data

This page describes how Stockisto isolates tenant data, authenticates users, enforces roles and protects the API. It is written for buyers, IT and security reviewers. Everything below describes how the application is built today. Stockisto runs in one environment, TEST, hosted in Azure Sweden Central. There is no production environment yet, so no uptime or support commitment applies.


Multi-tenant isolation

Stockisto is one deployment shared by many tenants. A tenant is a supplier, retailer or installer organization, or the internal Stockisto operator. Every row of business data carries a TenantId, and isolation is enforced in the data layer, not left to each query.

Tenant-scoped entities

Any entity that stores tenant data implements the ITenantScoped interface, which requires a TenantId. The isolation machinery keys off that one interface.

Global query filters (read isolation)

Each module's DbContext applies an EF Core global query filter to every ITenantScoped entity at startup. The filter is added by reflecting over the model, so a new tenant-scoped entity is covered without extra wiring:

modelBuilder.Entity<TEntity>()
    .HasQueryFilter(e => _currentTenantId == null || e.TenantId == _currentTenantId);

The current tenant is set once per request from the tenant_id claim on the caller's JWT. An ordinary query against retailers, products, analytics events or any other tenant-scoped table can only return rows from the caller's own tenant.

Why the filter is in the data layer

Putting isolation in the DbContext means a developer cannot forget a WHERE TenantId = … clause. Unless a query opts out explicitly, the tenant filter applies.

Write isolation

Read filters do not stop a buggy write from inserting a row with an empty TenantId. Such a row would escape every filter. Tenant-scoped contexts therefore register the TenantInsertGuardInterceptor. On SaveChanges it refuses to insert any ITenantScoped entity whose TenantId is empty and throws at write time:

Refusing to insert {EntityName} with an empty TenantId. Every ITenantScoped
row must be tenant-stamped before SaveChanges. An empty TenantId would orphan
the row and bypass tenant isolation filters.

Reads are filtered by tenant, and writes are guarded, so no unstamped row can be created.

Controlled bypass for operators

Cross-tenant access exists only for the internal operator path. Platform-operator code opts out with EF Core's IgnoreQueryFilters() explicitly: the StockistoAdmin console, the ingestion review queue and the tenant lifecycle service. Tenant-facing API controllers never bypass the filter on tenant data. Architecture tests pin the module boundaries this depends on.


Authentication

Stockisto uses short-lived JWT access tokens plus rotating HttpOnly refresh tokens. Sign-in methods: email and password, Google, magic link, and per-tenant SSO (OpenID Connect). All of them converge on the same token issuance and cookie logic. Platform operators must also complete MFA.

Tokens and cookies

On a successful sign-in the API issues a signed JWT and sets these cookies:

  • stockisto_token: the JWT access token. It is Secure on HTTPS and SameSite=Lax, and it is deliberately not HttpOnly: the app reads it from document.cookie and forwards it as a Bearer header. This is a documented, accepted trade-off, mitigated by the token's short life and the refresh rotation below. The API also reads it from the cookie when no Authorization header is present.
  • stockisto_refresh: the rotating refresh token. It is HttpOnly, Secure and SameSite=Lax, so page scripts can never read it. It is only ever sent back to the API to rotate a near-expiry access token.
  • stockisto_tenant: the tenant id, not HttpOnly, so client components can resolve the current tenant without parsing the token. It carries no authorization weight.

The cookies expire after 7 days. The access JWT itself expires after 15 minutes by default (Jwt:ExpiryMinutes); local development uses 8 hours. Sign-out deletes the cookies, and the logout endpoint is anonymous so an expired session can still log out cleanly.

The access JWT is signed with HMAC-SHA256 and validated on every request for issuer, audience, lifetime and signing key, with a 30-second clock-skew tolerance. It carries these claims:

  • sub: user id
  • email
  • jti: unique token id
  • tenant_id, tenant_type, tenant_slug
  • one role claim per assigned role
  • scoped_retailer_id: only for RetailerDataManager users (see RBAC)

Refresh tokens and rotation

Alongside the access token the API issues a refresh token: random bytes, stored server-side as a hash. Refresh tokens have a 7-day absolute window (Jwt:ExpiryDays) that refreshing cannot extend.

Calling the refresh endpoint rotates the token: the stored hash is replaced, which invalidates the previous value. A second use of an old token finds no match and is rejected with 401. A password change or MFA re-enrollment changes the user's security stamp, which also invalidates every outstanding refresh token.

No token in localStorage

The access token lives in a cookie, never in localStorage, and the refresh token is HttpOnly so scripts cannot read it at all. Cross-origin calls use credentials: include so the browser attaches the cookies.

Password policy and lockout

For password accounts, ASP.NET Core Identity enforces:

  • Minimum length 8, at least one digit
  • Lockout after 5 failed attempts, for 15 minutes

Login fails closed: inactive users, missing tenants and suspended tenants are rejected before any token is issued. A suspended tenant receives 403 Account suspended.

Google sign-in

The Google flow redirects to Google's consent screen. A completion endpoint reads the Google identity claims (email, name), finds or provisions the matching user, and issues the same cookies as any other sign-in. New Google users get a trial Supplier tenant. Inactive users and suspended tenants are rejected here too.

Magic link

Magic-link sign-in generates a single-use token through ASP.NET Identity's data-protection provider and emails it as a verify link. The token expires shortly after issue and works once. To prevent email enumeration, the request endpoint always answers "If that email exists, a magic link has been sent." See the Getting Started guide for the end-user flow.

Single sign-on

A tenant can connect its own OpenID Connect identity provider. Sign-in then starts at /api/v1/auth/sso/{tenantSlug}/start and returns through the provider's callback into the same cookie session.


Roles & access control (RBAC)

Authorization is role-based, enforced by ASP.NET Core authorization policies and [Authorize] attributes on controllers. Roles are seeded at startup and carried as role claims in the JWT:

RoleScopeTypical capabilities
SupplierAdminOne supplier tenantFull management of the supplier's retailers, locator and settings
RetailerAdminOne retailer tenantManage the retailer's own profile and data
StockistoAdminPlatform operatorCross-tenant administration, suspension, erasure
SupplierViewerOne supplier tenantRead-only view within the supplier tenant
RetailerDataManagerA single retailerManage data for one specific retailer only (scoped_retailer_id)

Named policies map to these roles (SupplierAdminOnly, RetailerAdminOnly, StockistoAdminOnly and AnyAdmin). Endpoints declare the roles they accept.

Scoped permissions

Two roles are least-privilege templates:

  • SupplierViewer grants read-only access within a supplier tenant.
  • RetailerDataManager is scoped to exactly one retailer. An invitation for this role must name a ScopedRetailerId; the service rejects it otherwise. That retailer id travels as the scoped_retailer_id claim, so the user can only act on that retailer.

Invitations

Team members are added by invitation. Only SupplierAdmin, RetailerAdmin or StockistoAdmin can invite, and the new user is always created inside the inviting user's tenant. Accepting the invitation is the only anonymous step, gated by a valid, unexpired token. See the Sharing + Groups guide for how locator data is delegated across organizations.

API keys

Integrations use API keys from the Developers page instead of a user session. A key is shown once, stored as a hash, scoped to api:read or api:write, and pinned to its tenant by the same query filters. Webhook deliveries are signed with HMAC-SHA256 and a rotatable secret. See the Public imports API.


API security controls

Beyond authentication and isolation, the API applies request-level protections in a fixed pipeline order.

Security headers

Every API response carries:

  • Strict-Transport-Security: one-year HSTS with includeSubDomains
  • X-Content-Type-Options: nosniff
  • X-Frame-Options: DENY: the API returns JSON, never embeddable HTML
  • Referrer-Policy: strict-origin-when-cross-origin
  • Content-Security-Policy: default-src 'none'; frame-ancestors 'none' on JSON endpoints

CORS and CSRF

The default CORS policy allows only the configured first-party app origins and permits credentials, because the session lives in a cookie. A state-changing request whose Origin header is not on that allowlist is refused before authentication.

A separate embed policy covers the anonymous locator endpoints the widget calls. It allows any origin and never allows credentials, so a widget on a third-party page can read public store data but can never carry a Stockisto session. Analytics and lead attribution from an embed are credited only when the page origin is on the supplier's declared Allowed origins list.

Rate limiting

The API enforces sliding-window limits, partitioned per tenant for authenticated traffic and per client IP for anonymous traffic:

ScopeLimit
Global (authenticated API)500 requests per minute per tenant
Locator search100 per minute per tenant, 20 per minute per anonymous IP
Public store and brand reads200 per minute per tenant, 80 per minute per anonymous IP
Embed config500 per minute
Analytics ingestion10,000 per minute per tenant, 1,200 per anonymous IP
Login, register, refresh10 per minute per IP
Public API keysPer key, scaled by an operator-set multiplier

When a limit is exceeded the API returns HTTP 429 with a Retry-After header and a JSON body (error: "rate_limit_exceeded").

Abuse detection

An abuse-detection layer runs before rate limiting:

  • IPs on the blocklist receive 403.
  • Rate-limit rejections are counted per anonymous IP; 50 rejections within 10 minutes trigger an automatic one-hour block.
  • Requests with empty or known scraper user agents (curl, wget, python-requests) are logged, not blocked.

The blocklist check fails closed: if its backing store is unavailable, the API answers 503 rather than admitting traffic unchecked.

Behind Azure Front Door

The API sits behind Azure Front Door, which sets X-Forwarded-For. Rate limiting and abuse detection read the real client IP from that header, not from the connection.


Data handling

Where data lives

Tenant data is stored in PostgreSQL with PostGIS for retailer locations, in Azure Sweden Central. Each bounded context (Identity, Network, Catalog, Analytics and others) owns its own schema and migration history. Data is encrypted in transit and at rest by the Azure services that hold it.

Personal data

Personal data about your staff is concentrated in the identity layer: a user's email, phone number and user name, the email on a pending invitation, and the actor email on tenant audit rows. Consumer personal data enters only through consumer features: reservations (name, email, phone, notes), back-in-stock alerts (email) and installer leads. Analytics events carry opaque session and correlation ids, never an email.

Retention

DataRetention
Raw analytics events30 days, then purged
Reservation contact detailsScrubbed 30 days after the reservation expires
Back-in-stock alert emailScrubbed 30 days after the alert fires or expires
Data export archivesDownloadable for 7 days, then deleted
Diagnostic telemetry30 days on the current environment

Secrets management

Application secrets (the JWT signing key, Google credentials, connection strings) are stored in Azure Key Vault and reach the app as Key Vault references resolved by the App Service. The app refuses to start on an unresolved reference, so a missing secret is a failed deploy, not a silent fallback. Local development uses user-secrets; no Key Vault secret is needed to run the app locally.

Audit logging

Tenant lifecycle actions (suspend, reactivate, delete) are written to a tenant event log with the event type, actor email and timestamp. Sensitive admin actions and API-key changes write to the same log. For erasure, the audit entry is written before any data is deleted, so a record that erasure started exists even if the deletion is interrupted.

Data export and deletion

A SupplierAdmin or RetailerAdmin can request a full data export and a tenant deletion from Settings in their admin app. A deletion request waits 14 days before it runs, and can be cancelled in that window.

Tenant erasure

Erasure cascades across every module in a fixed order, bypassing the tenant filters as an operator action:

  1. Write the erasure ledger entry first (proof of intent)
  2. Activation data: locator projects, install-health snapshots, reservations, custom domains
  3. Raw analytics events
  4. Entitlement usage records
  5. Network data: retailers, locations, supplier-retailer relationships
  6. Module erasers, then Identity data: invitations, users, event logs, and the tenant row
  7. Export archives and the deletion request itself
  8. Mark the ledger complete

A completed erasure can be retried; the retry repeats the raw analytics purge to catch late writers.

Erasure is irreversible

Erasure permanently deletes all of a tenant's data across every module. Suspension, which is reversible, is the right tool when you only need to disable access without destroying data.

The widget on your site

The embeddable widget sets no cookies. With analytics consent off (the default) it sends no events and writes nothing to session storage. It caches a brand's theme in local storage so repeat visits paint faster. Browser location is requested only after the visitor asks for it inside the widget.


Reviewer summary

  • Isolation: every tenant-scoped table is filtered by TenantId through EF Core global query filters; writes are guarded against unstamped rows; cross-tenant reads need an explicit, operator-only opt-out.
  • Authentication: short-lived HMAC-SHA256 access tokens in a Secure, SameSite=Lax cookie that is deliberately not HttpOnly; a rotating 7-day refresh token in an HttpOnly cookie with reuse detection; password lockout; enumeration-safe magic links; MFA for platform operators.
  • Authorization: five roles including two least-privilege scoped templates; tenant-bounded invitations; hashed, scoped API keys.
  • Transport and abuse: HSTS and hardening headers, first-party CORS allowlist with a separate credential-free embed policy, per-tenant and per-IP rate limits, automatic IP blocking on sustained abuse, Azure Front Door in front.
  • Data: per-context PostgreSQL schemas in Azure Sweden Central, Key Vault secrets, audit logging, self-service export and deletion, and an ordered, fully cascading erasure path.

For onboarding and product flows, see the Getting Started guide. For how retailer data enters the system, see the Data Import guide.

cebf50c · 2026-10-05 22:57