Customer portal — architecture
Separate auth domain (ADR-005)
Portal customers are not Nextcloud users. The portal authenticates them with
a bearer token against pipelinq-portal-register-backed portalAccount /
portalSession records. A portal token grants access only to /portal/api/*;
it is never accepted by Nextcloud middleware, and a Nextcloud session never
grants portal access.
Browser ──Bearer token──▶ /portal/api/* ──▶ PortalRequestGuard
(tenant resolve + session validate)
│
┌────────────────────────┴───────────────────────┐
▼ ▼
pipelinq-portal register main pipelinq register
(account/session/delegation/audit/config) (read facades: invoices=posTransaction,
contracts, orders, requests)
Request lifecycle
- Every
/portal/api/*controller method is@PublicPage(no NC user). PortalRequestGuard::resolveTenant()derives the tenant from the host / subdomain /X-Portal-Tenantheader — never from a body or query parameter.PortalRequestGuard::authenticate()readsAuthorization: Bearer <token>, validates it viaPortalSessionManager(hash lookup + not-revoked + not-expired + tenant match), and loads the bound account.- The controller calls a service scoped to that account. Identity is always the token's account, never a client-supplied id.
Tokens and secrets
- Session token: 256-bit
ISecureRandom, shown once at login, stored only as a SHA-256 hash. 8-hour default TTL (tenant-configurable), extendable. - Reset / email-verify / close tokens (
PortalTokenService): single-purpose, hashed, short-lived, constant-time verify. - Password: argon2id via
IHasher. MFA secret: TOTP (RFC 6238) encrypted at rest viaICrypto. Document links: HMAC-SHA256 over{objectId, objectType, accountId, issuedAt, expiresAt}, 5-minute TTL.
Per-customer scoping (the IDOR boundary)
PortalScopeResolver computes, per account and scope, the set of contact/client
ids the account may read — its own, plus any grantor's under a valid delegation
of that exact scope. classify() returns null (own), a grantor id
(delegated), or false (not visible → caller returns 404). Every read facade
(AbstractPortalReadFacade) and the request service filter strictly through
this, so a guessed id from another customer/tenant is indistinguishable from a
non-existent one.
Multi-tenant isolation (REQ-002)
Tenant id is server-resolved only; cross-tenant access returns 404 (not 403) to
avoid existence leaks. No endpoint accepts a tenantId from the client.
CSRF posture
The portal uses bearer tokens in the Authorization header, not an ambient
cookie, so its public POSTs are not CSRF-able; they are marked @NoCSRFRequired
deliberately. Admin endpoints carry no @PublicPage/@NoAdminRequired and are
therefore admin-only by Nextcloud's SecurityMiddleware default (re-asserted in
the controller body).
Register fragment (ADR-037)
The portal register/schemas are added via lib/Settings/register.d/40-portal.json
— never by editing the monolith. ConfigFileLoaderService::deepMergeConfig
additively unions components.objects[] and each register's schemas[]
membership (the fleet-standard rule added with this change), so the fragment
cannot clobber the monolith's seed objects.
Audit trail
PortalAuditService writes append-only portalAuditEvent records (no updates,
no deletes) for every sensitive action, queryable by the account (own) and by a
DPO (whole tenant).
WCAG 2.2 AA
Brand colours are contrast-validated at save time
(ContrastRatioCalculator, WCAG relative-luminance formula). The Vue components
use semantic tables/labels, aria-describedby error association, a
role="alert" aria-live="polite" session-timeout region, and a visible focus
indicator.