🌐

PORTAL

P4 · Long-term Planned · P4 long-term Owner: CCO seat + CPO

The branded customer-facing surface. The agency logs into CyberOS; the client logs into a white-labelled, custom-domain portal that surfaces only their data — projects, invoices, signed documents, support threads — and a branded AI assistant with project history context.

PORTAL is the second front-door — the one the agency's clients see. It is multi-tenant within multi-tenant: a CyberOS tenant (the agency) operates one or more Client Accounts, each of which has its own ClientMembers (the client's people), its own white-label theme (logo + colours + custom CNAME), and a scoped view of the agency's data: only the projects this client is involved in, only this client's invoices, only this client's signed documents, only this client's CHAT threads. The signing flow uses DOC (same QTSP chain); the project view is a read-narrowed lens on PROJ; the invoice view shows status + pay link from INV. The client AI assistant is a branded CUO with the project history pre-loaded — "what's the status of milestone 3?" gets a grounded answer with citations to PROJ issues. Client-initiated workflows let the customer file a new project request, raise a billing inquiry, or open a support ticket — each of which creates a CHAT thread on the agency side. SSO with the client's IdP (SAML, OIDC) means the client signs in once at their own identity provider; ClientMembers never need a CyberOS password. PDPL Art. 14 DSAR support: the client can export their account data; GDPR Art. 17: they can delete it with a 30-day grace period. Localization: Vietnamese + English; multi-currency display. PRD §9.21 and SRS §7.21 lock the FRs.

PORTAL is CyberOS's white-labelled client-facing surface. A tenant's customers log in at clients.<tenant>.cyberos.world or at their own CNAME (e.g. portal.acmecorp.com) and see a portal that looks like the agency's brand: logo, colours, typography, sub-brand accents per (FR pending). The data plane is a permission-narrowed lens on the underlying agency data: PROJ projects scoped to this client, INV invoices scoped to this client, DOC signed-docs scoped to this client, CHAT threads scoped to this client. Cross-tenant isolation in the shared infrastructure is enforced at three layers: Postgres RLS, Apollo Router scope checks, and per-bucket S3 prefix ACLs. Authentication is via SSO from the client's IdP (SAML 2.0 or OIDC); ClientMembers are mapped to internal Subject rows via JIT provisioning at first login. A branded client AI assistant — a CUO variant — has the client's project history pre-loaded and answers grounded questions with citations. Client-initiated workflows (new project request, billing inquiry, support ticket) materialise as CHAT threads on the agency side, automatically routed to the right AM. Vietnamese + English UI; multi-currency rendering for international clients.

Status
Planned
P4 long-term
Subdomain
clients.{t}.cyberos.world
+ CNAME white-label
SSO
SAML 2.0 + OIDC
JIT provisioning
Data lens
PROJ · INV · DOC · CHAT
scoped read-only
AI assistant
branded CUO
project history context
i18n
vi + en
multi-currency display
PWA
install supported
mobile-first
Depends on
AUTH · BRAIN · 4 modules
+ CDN · TLS · S3
1

Why PORTAL exists

Consultancies maintain an awkward parallel reality: project status lives in Linear (which the client can't see), invoices live in Xero / QuickBooks (also closed), signed contracts live in DocuSign (separate login per agreement), and the client gets emailed PDFs and Loom links. The client wants one place to log in and see "what is going on with our account?" — and the agency wants that place to feel like the agency's brand, not a generic SaaS dashboard. PORTAL is that place. It is the same data the agency operates from, viewed through a permission-narrowed and brand-tinted lens.

🎨
White-label by default

Logo, colour accents, custom CNAME, branded email templates. The portal looks like an extension of the agency, not a third-party tool.

🔐
Tenant isolation by construction

Postgres RLS by tenant_id × client_account_id; Apollo Router scope-required directive on every field; S3 bucket prefix scoped per client. Three layers of fail-safe.

🤖
Branded AI assistant

A client variant of CUO with the project history loaded. "What's our SOW spend so far?" returns a grounded answer with INV citations.

The bet is that a portal that surfaces the same data the agency operates from — rather than a Notion page someone has to remember to keep updated — increases client retention because trust is built in transparency, and reduces inbound "what's the status of X" emails because the answer is one click and one branded AI prompt away.

2

What it does — 5W1H2C5M

Structured decomposition. Cells trace to PRD §9.21 + SRS §7.21.

AxisQuestionAnswer
5W · WhatWhat is PORTAL?A second front-door for clients. Branded read-narrowed views of PROJ + INV + DOC + CHAT; client-initiated workflow channels; branded CUO AI assistant; SSO from client IdP; custom-domain CNAME.
5W · WhoWho uses it?ClientMembers: the customer's people (CEO, project sponsor, billing contact). Agency CCO: owns portal configuration per client. Agents: branded CUO answers grounded questions with citations; CCO-skill summarises client signals across portals.
5W · WhenWhen does it run?Continuous. Real-time WebSocket for project status updates and signing-notification deeplinks. Branded email digests on a per-client schedule.
5W · WhereWhere does it run?P4: multi-region edge (CloudFront / Vercel) for the static SPA; SG-1 + VN-hanoi-1 for the API. Custom CNAMEs terminate at edge with per-tenant ACM certs.
5W · WhyWhy a separate module?Because the auth surface, the brand-theme system, the data-lens narrowing, and the client AI assistant differ enough from internal CyberOS that compositing them into the main app would muddy both. PORTAL is a focused product for a focused audience.
1H · HowHow does it work?SSO at edge resolves ClientMember → Subject row → scope contract grants narrowed to client_account_id. Federated GraphQL queries are predicate-narrowed by Apollo Router at request time. Branded SPA reads a tenant.branding config; custom CNAME terminates at edge.
2C · CostCost budget?P4: edge-served static SPA ($5/mo CloudFront); per-tenant ACM cert ($0). API: ~$20 / month per active client account at low traffic. Branded email templates pass through SES.
2C · ConstraintsConstraints?(a) zero cross-tenant data leakage — three-layer enforcement. (b) ClientMember access is read-only on tenant data; write only on their own consents / requests ((FR pending)). (c) Every portal action audited to BRAIN ((FR pending)). (d) PDPL Art. 14 + GDPR Art. 15 DSAR for ClientMembers. (e) Vietnamese-localised UI.
5M · MaterialsStack?Rust 1.81 · axum (API) · Next.js + React + Tailwind (SPA) · CloudFront / Vercel edge · ACM for per-tenant TLS · S3 for theme assets · SAML / OIDC libraries · OpenTelemetry.
5M · MethodsMethod choices?Predicate-narrowing at Apollo Router (every field has @requiresScopes + tenant + client_account predicate). JIT user provisioning at SSO callback. Branded CUO is a thin wrapper on the main CUO with persona-stamped scope.
5M · MachinesDeployment?Edge SPA + multi-region API. Per-tenant ACM cert auto-issued and renewed on CNAME claim. PWA service worker for offline-tolerant read views.
5M · ManpowerWho maintains?0.5 FTE at P4. CCO seat owns product surface; CTO owns brand-theming engine; CSO owns isolation invariants.
5M · MeasurementHow measured?N(FR pending) (zero tenant data leakage), N(FR pending) (TTFB p95 ≤ 500 ms at edge), KPI client-login MAU per tenant.
3

Architecture

PORTAL is two surfaces: an edge-served Next.js SPA per tenant brand, and a Rust API that materialises the data-lens at request time. SSO callback creates / refreshes a ClientMember → Subject mapping and issues a scoped JWT. Federated GraphQL queries are scope-narrowed by Apollo Router.

graph TB subgraph CLIENT ["Client environment"] BROWSER["Client browser
portal.acmecorp.com"] PWA["PWA install"] IDP["Client IdP
(Okta / Azure AD / GSuite)"] end subgraph EDGE ["Edge"] CDN["CloudFront / Vercel
edge SPA delivery"] ACM["Per-tenant ACM cert"] ROUTER["Apollo Router
scope predicate"] end subgraph PORTAL ["PORTAL service (Rust · axum)"] SSO_S["sso.rs
SAML + OIDC callback"] PROVISION["provision.rs
JIT ClientMember"] LENS["lens.rs
data-narrowing layer"] BRAND["brand.rs
theme config + CNAME issuance"] AI_PROXY["ai_proxy.rs
branded CUO wrapper"] WF["workflow.rs
client-initiated requests"] AUDIT["audit_bridge.rs"] end subgraph SCOPED_VIEWS ["Scoped read-only lenses"] PROJ_L["📋 PROJ lens
client_account_id filter"] INV_L["💰 INV lens"] DOC_L["✍️ DOC lens"] CHAT_L["💬 CHAT lens"] end subgraph STORES ["Stores"] PG[("PostgreSQL 16
client_account · client_member
branding · session
scoped_request")] S3[("S3
theme assets (logo / favicon)")] KMS[("KMS")] end subgraph AGENCY ["Agency side"] CHAT_A["💬 CHAT
incoming thread"] CUO_A["🧠 CUO
route to AM"] BRAIN["🧠 BRAIN
portal.* rows"] OBS["👁 OBS"] end BROWSER --> CDN PWA --> CDN CDN --> ACM CDN --> ROUTER BROWSER --> IDP IDP --> SSO_S SSO_S --> PROVISION PROVISION --> PG ROUTER --> LENS LENS --> PROJ_L LENS --> INV_L LENS --> DOC_L LENS --> CHAT_L BRAND --> S3 BRAND --> ACM BROWSER --> AI_PROXY AI_PROXY --> CUO_A WF --> CHAT_A CHAT_A --> CUO_A CUO_A --> BRAIN PORTAL --> AUDIT AUDIT --> BRAIN PORTAL --> OBS classDef planned fill:#ffe4e6,stroke:#9f1239 classDef store fill:#f5f3ff,stroke:#7c3aed classDef sink fill:#f5ede6,stroke:#45210e class CDN,ACM,ROUTER,SSO_S,PROVISION,LENS,BRAND,AI_PROXY,WF,AUDIT,PROJ_L,INV_L,DOC_L,CHAT_L,CHAT_A,CUO_A planned class PG,S3,KMS store class BRAIN,OBS sink

Internal components

ComponentPath (planned)Responsibility
sso.rsservices/portal/src/sso.rsSAML 2.0 + OIDC callback endpoints. Parses assertion, validates issuer, extracts attributes.
provision.rsservices/portal/src/provision.rsJust-in-Time ClientMember provisioning. Creates Subject + ClientMember + ScopeContractGrant on first login.
lens.rsservices/portal/src/lens.rsData-lens narrowing layer. Wraps every federated query with a (tenant_id, client_account_id) predicate; rejects queries that try to escape.
brand.rsservices/portal/src/brand.rsBranding config CRUD: logo URL, favicon, colour anchors, custom email-template overrides, CNAME claim + ACM cert issuance.
cname.rsservices/portal/src/cname.rsCNAME validation. DNS TXT challenge for ownership verification; ACM cert request via AWS API; automatic renewal.
theme.rsservices/portal/src/theme.rsTheme runtime: resolves brand config per request hostname; emits CSS variable bundle in HTML head.
ai_proxy.rsservices/portal/src/ai_proxy.rsBranded CUO wrapper. Sets persona to client-cuo with scope narrowed to ClientMember context.
workflow.rsservices/portal/src/workflow.rsClient-initiated workflow handlers. New-project request, billing inquiry, support ticket. Materialises CHAT thread on agency side.
i18n.rsservices/portal/src/i18n.rsUI-string resolution. Default tenant locale + per-ClientMember override; currency localisation.
email.rsservices/portal/src/email.rsBranded transactional email. Per-tenant template overrides; SES via tenant FROM with SPF/DKIM.
consent.rsservices/portal/src/consent.rsClient consents (data-processing, marketing, AI usage). Write-allowed for the ClientMember on their own row only.
dsar.rsservices/portal/src/dsar.rsDSAR / right-to-erasure surface for the ClientMember's own data; 30-day grace before destructive delete.
audit_bridge.rsservices/portal/src/audit_bridge.rsBRAIN canonical writer. Every portal action: login, view, request, consent, dsar.
migrations/services/portal/migrations/sqlx migrations. RLS by (tenant_id, client_account_id). Per-tenant brand config schema.
PORTAL-INV-001 · Cross-tenant data leakage MUST be zero

Three-layer fail-safe: (1) PostgreSQL RLS keyed by (tenant_id, client_account_id) on every relevant table; (2) Apollo Router @requiresScopes + predicate-narrowing on every GraphQL field; (3) S3 prefix-scoped IAM for asset access. CI gate: cross-tenant + cross-client-account read attempts via property-based fuzz. Production gate: SOC 2 CC6.1 + annual pen-test cover this explicitly. Verification: services/portal/tests/isolation_invariant.rs.

4

Data model

PORTAL owns six primitives: ClientAccount (the customer organisation), ClientMember (the customer's people), BrandingTheme (per-client visual config), ClientSession (active session), ScopedData (cached lens output), ConsentRecord (data-processing consents), ScopedRequest (client-initiated workflow).

erDiagram TENANT ||--o{ CLIENT_ACCOUNT : "operates" CLIENT_ACCOUNT ||--o{ CLIENT_MEMBER : "has" CLIENT_ACCOUNT ||--o| BRANDING_THEME : "themed by" CLIENT_ACCOUNT ||--o{ SCOPED_REQUEST : "submitted" CLIENT_MEMBER ||--o{ CLIENT_SESSION : "creates" CLIENT_MEMBER ||--o{ CONSENT_RECORD : "grants" CLIENT_MEMBER ||--o{ SCOPED_REQUEST : "submits" CLIENT_ACCOUNT ||--o{ PROJ_SCOPE : "sees only these" CLIENT_ACCOUNT ||--o{ INV_SCOPE : "sees only these" CLIENT_ACCOUNT ||--o{ DOC_SCOPE : "sees only these" TENANT { uuid id PK string slug } CLIENT_ACCOUNT { uuid id PK uuid tenant_id FK string display_name string slug "for default subdomain" string custom_cname "optional" string acm_cert_arn "if custom CNAME" string status "active | suspended | offboarding" timestamp created_at } CLIENT_MEMBER { uuid id PK uuid client_account_id FK uuid subject_id FK "AUTH Subject row" string email string display_name string role "owner | member | viewer" string idp_subject_claim "stable IdP identifier" timestamp first_login_at timestamp last_login_at bool active } BRANDING_THEME { uuid id PK uuid client_account_id FK string logo_url "s3:// asset" string favicon_url string accent_color string typography_pack bytea email_template_overrides timestamp updated_at } CLIENT_SESSION { uuid id PK uuid client_member_id FK string jti "JWT id" timestamp expires_at string ip_address string user_agent string locale "vi | en" string status "active | revoked | expired" } CONSENT_RECORD { uuid id PK uuid client_member_id FK string consent_type "data_processing | marketing | ai_usage" bool granted string version "consent doc version hash" timestamp granted_at string brain_chain } SCOPED_REQUEST { uuid id PK uuid client_account_id FK uuid client_member_id FK string kind "new_project | billing_inquiry | support_ticket | dsar_request | erasure_request" string title string body string status "open | routed | resolved | declined" string chat_thread_id "agency-side CHAT thread" timestamp created_at } PROJ_SCOPE { uuid client_account_id FK uuid project_id FK string lens_role "owner | observer" } INV_SCOPE { uuid client_account_id FK uuid invoice_id FK } DOC_SCOPE { uuid client_account_id FK uuid document_id FK }

Predicate-narrowing in Apollo Router

# router-portal.yaml
authorization:
  require_authentication: true
  preview_directives:
    enabled: true

# Every read of an upstream resource is rewritten:
#   query Project(id: ID!)
# becomes (via plugin):
#   query Project(id: ID!) {
#     project(id: $id, _portal_scope: { tenant_id: $jwt.tenant, client_account_id: $jwt.client_account })
#   }
# The subgraph SDL declares _portal_scope as a required argument when invoked with `aud: portal`.

# Three-layer fail-safe:
#  1. Predicate-narrowing here at the Router
#  2. RLS at Postgres (tenant_id × client_account_id)
#  3. Field-level @requiresScopes(scopes: [["portal.read"]])
5

API surface

Federated GraphQL subgraph for the SPA, REST endpoints for SSO + CNAME ops, MCP tools for branded AI assistant.

GraphQL subgraph (federated, audience=portal)

extend schema
  @link(url: "https://specs.apollo.dev/federation/v2.5", import: ["@key", "@external", "@shareable", "@requiresScopes"])

type ClientAccount @key(fields: "id") {
  id: ID!
  displayName: String!
  customCname: String
  branding: BrandingTheme
  members: [ClientMember!]! @requiresScopes(scopes: [["portal.admin"]])
}

type ClientMember @key(fields: "id") {
  id: ID!
  email: String!
  displayName: String!
  role: ClientMemberRole!
  lastLoginAt: DateTime
}

type BrandingTheme {
  logoUrl: String
  faviconUrl: String
  accentColor: String!
  typographyPack: String!
}

type ScopedRequest @key(fields: "id") {
  id: ID!
  kind: RequestKind!
  title: String!
  body: String!
  status: RequestStatus!
  createdAt: DateTime!
  chatThreadId: ID
}

type ConsentRecord {
  consentType: ConsentType!
  granted: Boolean!
  version: String!
  grantedAt: DateTime!
}

enum ClientMemberRole { OWNER MEMBER VIEWER }
enum RequestKind { NEW_PROJECT BILLING_INQUIRY SUPPORT_TICKET DSAR_REQUEST ERASURE_REQUEST }
enum RequestStatus { OPEN ROUTED RESOLVED DECLINED }
enum ConsentType { DATA_PROCESSING MARKETING AI_USAGE }

# Federated lenses (read-only, scope-narrowed automatically):
extend type Project @key(fields: "id") {
  id: ID! @external
  # only fields visible from portal:
  name: String! @external
  status: String! @external
  milestones: [Milestone!]! @external
  # comments restricted: client can read AM-posted comments, post their own
}

extend type Invoice @key(fields: "id") {
  id: ID! @external
  amount: Float! @external
  currency: String! @external
  status: String! @external
  payLink: String @external
}

extend type Document @key(fields: "id") {
  id: ID! @external
  name: String! @external
  status: String! @external
  signerLink: String  # personalised for the ClientMember
}

type Query {
  me: ClientMember! @requiresScopes(scopes: [["portal.read"]])
  myAccount: ClientAccount! @requiresScopes(scopes: [["portal.read"]])
  myProjects: [Project!]! @requiresScopes(scopes: [["portal.read"]])
  myInvoices: [Invoice!]! @requiresScopes(scopes: [["portal.read"]])
  myDocuments: [Document!]! @requiresScopes(scopes: [["portal.read"]])
  myRequests: [ScopedRequest!]! @requiresScopes(scopes: [["portal.read"]])
}

type Mutation {
  submitRequest(input: ScopedRequestInput!): ScopedRequest!
    @requiresScopes(scopes: [["portal.write_request"]])
  grantConsent(consentType: ConsentType!, version: String!): ConsentRecord!
    @requiresScopes(scopes: [["portal.write_consent"]])
  revokeConsent(consentType: ConsentType!): ConsentRecord!
    @requiresScopes(scopes: [["portal.write_consent"]])
  requestDsarExport: ScopedRequest! @requiresScopes(scopes: [["portal.dsar"]])
  requestErasure: ScopedRequest! @requiresScopes(scopes: [["portal.erasure"]])
  updateLocale(locale: String!): ClientMember! @requiresScopes(scopes: [["portal.read"]])
}

REST endpoints

MethodPathPurpose
GET/portal/.well-known/saml-metadataSAML 2.0 metadata for the client's IdP.
POST/portal/sso/saml/acsSAML assertion consumer URL.
GET/portal/sso/oidc/callbackOIDC callback.
POST/portal/brandingUpdate branding theme. CCO scope.
POST/portal/branding/cnameClaim custom CNAME (TXT challenge issued).
GET/portal/branding/cname/{id}/verifyVerify CNAME TXT + request ACM cert.
GET/portal/assets/{theme_id}/logoServe logo (CDN-cached).
POST/portal/dsar/exportGenerate DSAR bundle.
POST/portal/erasure/confirmConfirm erasure after 30-day grace.

MCP tool catalogue (branded CUO)

Tool nameInputsOutputsAnnotations
cyberos.portal.my_projectsProject[]readonly · scope=portal.read
cyberos.portal.project_statusproject_id{milestones, comments}readonly
cyberos.portal.invoice_summaryyear?{outstanding, paid, …}readonly
cyberos.portal.submit_requestkind, title, body{ok, request_id}scope=portal.write_request · human-confirm
cyberos.portal.grant_consentconsent_type, version{ok}scope=portal.write_consent
cyberos.portal.dsar_request{request_id}scope=portal.dsar · email-confirm
cyberos.portal.find_docname_matchDocument[]readonly
6

Key flows

Flow 1 — Client SSO login (Okta SAML)

sequenceDiagram autonumber participant U as Acme PM (browser) participant CDN as edge CDN (portal.acmecorp.com) participant IDP as Acme Okta IdP participant S as sso.rs participant P as provision.rs participant AUTH as 🔐 AUTH participant PG as PostgreSQL participant B as 🧠 BRAIN U->>CDN: GET portal.acmecorp.com CDN-->>U: branded SPA (theme: acme) U->>S: GET /portal/login → redirect to Acme Okta S-->>IDP: SAML AuthnRequest IDP-->>U: Acme SSO form U->>IDP: authenticate IDP-->>S: SAML assertion (POST /portal/sso/saml/acs) S->>S: validate issuer, signature, audience S->>P: provision_if_needed(idp_subject_claim, attributes) P->>PG: SELECT client_member WHERE idp_subject_claim alt new ClientMember P->>AUTH: createSubject(kind=human, tenant=acme) P->>PG: INSERT client_member, scope_contract_grant P->>B: append client_member.provisioned row else returning ClientMember P->>PG: UPDATE last_login_at end S->>AUTH: token_exchange → portal-scoped JWT AUTH-->>S: access_token (aud=portal, scope=portal.read..., tenant, client_account) S->>B: append portal.login row {ip, ua, method:saml} S-->>U: 302 to / with JWT in cookie U->>CDN: GraphQL queries with predicate-narrowing

Flow 2 — View project (permission-scoped)

sequenceDiagram autonumber participant U as Acme PM participant SPA as portal SPA participant AR as Apollo Router (audience=portal) participant L as lens.rs (predicate-narrow) participant PROJ as 📋 PROJ subgraph participant PG as PostgreSQL (RLS) U->>SPA: open /projects SPA->>AR: query myProjects AR->>AR: verify JWT aud=portal, scope=portal.read AR->>L: inject (tenant_id, client_account_id) predicate L->>PROJ: query projects(_portal_scope: {…}) PROJ->>PG: SELECT … FROM project
WHERE tenant_id=$t AND client_account_id=$c (RLS additionally enforces this) PG-->>PROJ: rows (scoped) PROJ-->>L: results L-->>AR: scoped result AR-->>SPA: data SPA-->>U: rendered "My projects" — only Acme work

Three-layer fail-safe: Apollo Router scope check, Router predicate-narrowing, Postgres RLS. A single misconfigured layer is caught by the other two.

Flow 3 — Client submits a support ticket

sequenceDiagram autonumber participant U as Acme PM participant SPA as portal SPA participant W as workflow.rs participant CHAT as 💬 CHAT (agency) participant CUO as 🧠 CUO router participant AM as Account Manager participant B as 🧠 BRAIN U->>SPA: "New support ticket → My SOW is missing milestone 3" SPA->>W: submitRequest(kind=support_ticket, title, body) W->>CHAT: create thread in agency CHAT, channel = #acme-portal CHAT-->>W: thread_id W->>PG: INSERT scoped_request {kind, chat_thread_id, status=routed} W->>CUO: route the new thread CUO->>CUO: identify owning AM (CRM.account.am_owner) CUO->>AM: notify "New portal request from Acme PM (Jane Doe)" W->>B: append portal.request_submitted row W-->>SPA: "Ticket #SR-001234 opened" SPA-->>U: confirmation + ETA

Flow 4 — Branded client AI assistant (CUO with context)

sequenceDiagram autonumber participant U as Acme PM participant SPA as portal SPA "Ask" participant A as ai_proxy.rs participant CUO as 🧠 CUO (persona=client-cuo) participant AI as 🧠 AI gateway participant LENS as lens.rs (read context only Acme data) participant PROJ as 📋 PROJ participant INV as 💰 INV participant B as 🧠 BRAIN U->>SPA: "What's our SOW spend so far this quarter?" SPA->>A: prompt + ClientMember context A->>CUO: invoke with persona=client-cuo, scope=portal.read CUO->>LENS: pull context (scoped to Acme client_account) LENS->>PROJ: scoped query: active projects, milestones LENS->>INV: scoped query: paid + outstanding YTD PROJ-->>LENS: rows INV-->>LENS: rows CUO->>AI: generate answer with citations to INV-2026-0421, INV-2026-0488 AI-->>CUO: grounded paragraph + citations CUO->>B: append portal.ai_answer row {answer, citations} CUO-->>SPA: answer rendered with branded UI SPA-->>U: "You've spent $84,200 of the $120k SOW this quarter…"

The branded CUO is the same CUO process with persona=client-cuo and scope narrowed. It can only see this client's data; queries that try to escape the lens return zero results without surfacing the existence of other tenants.

Flow 5 — White-label CNAME claim + ACM cert

sequenceDiagram autonumber participant CCO as Agency CCO participant SPA as agency admin participant B_S as brand.rs participant C as cname.rs participant DNS as customer DNS participant ACM as AWS ACM participant CDN as CloudFront / Vercel participant BR as 🧠 BRAIN CCO->>SPA: enter desired CNAME "portal.acmecorp.com" SPA->>B_S: claim CNAME for client_account=acme B_S->>C: issue TXT challenge nonce C-->>SPA: "Add TXT _cyberos-challenge.portal.acmecorp.com = $nonce" CCO->>DNS: configure TXT record at Acme DNS CCO->>SPA: click "Verify" SPA->>C: verify TXT C->>DNS: DNS-over-HTTPS lookup DNS-->>C: nonce found ✓ C->>ACM: request cert for portal.acmecorp.com (DNS-validated) ACM-->>DNS: ACM challenge TXT CCO->>DNS: add ACM challenge TXT ACM-->>C: cert issued (cert_arn) C->>CDN: attach cert to distribution C->>BR: append branding.cname_activated row C-->>SPA: "portal.acmecorp.com is live"
7

Client-account lifecycle

A ClientAccount traverses four states from provisioning to closed. ClientMember provisioning is JIT on first SSO callback.

stateDiagram-v2 [*] --> Provisioned: agency CCO creates client_account Provisioned --> Configuring: branding + CNAME + SSO setup in progress Configuring --> Active: SSO callback verified + first ClientMember logged in Active --> Suspended: CCO suspends (billing freeze, dispute) Suspended --> Active: CCO un-suspends Active --> Offboarding: tenant requests close OR CCO closes Offboarding --> GracePeriod: 30-day data export window GracePeriod --> Closed: data wiped per retention policy Closed --> [*]

Per-state actions

StateTriggerSide-effects
ProvisionedCCO creates ClientAccountDefault subdomain assigned; default branding applied; audit row.
ConfiguringSSO setup + branding uploadSAML metadata exposed; ACM cert requested (if CNAME).
ActiveFirst successful ClientMember SSO callbackPortal usable; data-lens active; AI assistant enabled.
SuspendedCCO actionClientMember login refused; existing JWTs revoked; SSO returns soft "account paused" UI.
OffboardingTenant or CCO closeClientMember can still log in for 30 days; banner "account closing on YYYY-MM-DD"; DSAR export tooling surfaced.
GracePeriodDay 1 of 30-day windowRead-only; export bundle pre-generated; emails sent to all ClientMembers.
ClosedDay 30 of graceData wiped per retention policy; audit row retained; CNAME / ACM cert released.
8

Functional Requirements

The CyberOS FR catalogue is being rebuilt one feature at a time via the open fr-author Agent Skill.

Previous FR enumerations were archived 2026-05-14 and are no longer reflected on this page. PRD/SRS narrative remains authoritative for the spec; specific FRs land here as they are re-authored.

9

Non-Functional Requirements

PORTAL NFRs focus on tenant isolation and edge-served performance.

NFR IDConcernTargetMeasurement
N(FR pending)Cross-tenant data leakage incidents= 0property fuzz + annual pen-test
N(FR pending)Cross-client-account leakage (within same tenant)= 0RLS verification harness + CI gate
N(FR pending)SAML assertion replay= 0NotOnOrAfter + InResponseTo enforced
N(FR pending)TTFB p95 (edge) for branded SPA≤ 500 ms (global)RUM + Lighthouse
N(FR pending)myProjects GraphQL p95≤ 350 msk6 + Apollo Router
N(FR pending)CNAME claim → first served request (cold)≤ 5 min (ACM cert issuance)end-to-end test
N(FR pending)Portal availability (28-day)≥ 99.95%SLO monitor
N(FR pending)Mobile Lighthouse score≥ 90 (perf)nightly Lighthouse run
N(FR pending)PWA install ratetracked KPIOBS
N(FR pending)WCAG 2.1 AA compliancefull coverageaxe-core audit
N(FR pending)Per-client infra cost≤ $20 / mo at typical loadmonthly billing
10

Dependencies

PORTAL depends on AUTH, BRAIN, and federated subgraphs from PROJ, INV, DOC, CHAT, CRM. AWS ACM + CloudFront for edge TLS + CNAME.

graph LR subgraph upstream ["PORTAL depends on"] AUTH["🔐 AUTH
SSO + scope"] BRAIN["🧠 BRAIN
audit chain"] OBS["👁 OBS"] PROJ["📋 PROJ subgraph"] INV["💰 INV subgraph"] DOC["✍️ DOC subgraph"] CHAT["💬 CHAT subgraph"] CRM["🏢 CRM
account ownership"] AI["🧠 AI Gateway
branded CUO"] ACM["AWS ACM"] CDN["CloudFront / Vercel"] S3_T["S3 (theme assets)"] end PORTAL["🌐 PORTAL"] subgraph downstream ["Used by"] CM["Client Members
(end users)"] AM["Agency AMs
(receive requests)"] DASH["📊 OBS dashboards"] end AUTH --> PORTAL BRAIN --> PORTAL OBS --> PORTAL PROJ --> PORTAL INV --> PORTAL DOC --> PORTAL CHAT --> PORTAL CRM --> PORTAL AI --> PORTAL ACM --> PORTAL CDN --> PORTAL S3_T --> PORTAL PORTAL --> CM PORTAL --> AM PORTAL --> DASH classDef planned fill:#ffe4e6,stroke:#9f1239 class AUTH,BRAIN,OBS,PROJ,INV,DOC,CHAT,CRM,AI,PORTAL,CM,AM,DASH,ACM,CDN,S3_T planned
11

Compliance scope

PORTAL is in the SOC 2 / ISO 27001 isolation hotspot and inherits client-data DSAR obligations.

Regulation / standardArticle / clausePORTAL feature that satisfies it
Vietnam PDPL (Law 91/2025)Art. 14 — DSARClient-side DSAR export ((FR pending)).
Vietnam PDPLArt. 13 — ConsentConsentRecord per ClientMember per consent_type.
GDPR (EU 2016/679)Art. 15 — Right of accessSame surface as PDPL.
GDPRArt. 17 — Right to erasureSelf-service erasure with 30-day grace ((FR pending)).
GDPRArt. 7 — Conditions for consentVersioned consent doc hash; ConsentRecord captures version.
SOC 2 Type IICC6.1 — Logical accessSAML / OIDC SSO; RBAC; ClientMember role tiers.
SOC 2 Type IICC6.6 — Restricted accessThree-layer predicate-narrowing + RLS + scope check.
ISO/IEC 27001:2022A.5.30 — ICT readiness for business continuityEdge CDN failover; multi-region API.
ISO/IEC 27018Privacy for PII in public cloudTenant isolation invariants + per-tenant ACM cert.
WCAG 2.1 AAAccessibility standardBranded SPA meets AA per axe-core audit.
OWASP ASVS L2Application Security Verification StandardSAML/OIDC verification checklist; XSS protection in branded content.
12

Risk entries

PORTAL-specific risks in the risk register.

IDRiskLikelihoodImpactOwnerMitigation
R-PORTAL-001Information disclosure — tenant client sees another tenant's dataMediumCatastrophicCSOThree-layer fail-safe: predicate-narrowing + RLS + scope; CI cross-tenant gate; pen-test pre-launch + annual.
R-PORTAL-002Cross-client-account leak within same tenantLowHighCSORLS by (tenant_id, client_account_id); GraphQL predicate enforced.
R-PORTAL-003SAML assertion forgery / replayLowHighCSOInResponseTo + NotOnOrAfter; assertion signature validated; relay-state CSRF token.
R-PORTAL-004XSS via branded content (logo URL, accent color)MediumMediumCTOStrict CSP; logo URL allow-list (s3:// only); colour values validated as hex.
R-PORTAL-005Custom CNAME hijack (subdomain takeover after CNAME drop)MediumHighCSOActive CNAME verification every 24h; alert on TXT drop; auto-suspend on hijack signal.
R-PORTAL-006ClientMember provisioning explosion via SSO attribute spamLowMediumCSORate-limit per (tenant, idp_subject); auto-suspend tenant on abuse threshold; CCO notification.
R-PORTAL-007Branded CUO leaks cross-tenant context via prompt injectionMediumHighCSOCaMeL enforcement; persona-stamped JWT cannot escalate; scope-narrowing at AI gateway.
R-PORTAL-008DSAR / erasure abuse — automated bot triggers mass-erasureLowMediumDPOEmail-confirmation step; 30-day grace; tenant CCO can pause if abuse detected.
R-PORTAL-009ACM cert expiry / renewal failureLowMediumCTOAuto-renewal monitored; OBS alert 14d before expiry; manual override path.
R-PORTAL-010Customer-MCP external-agent over-scopeMediumMediumCSOPer-tool consent gate; scope explicit per tool; revocable; audit trail.
13

KPIs

PORTAL health rolls up into 10 KPIs covering activation, isolation, AI usage, and DSAR fulfilment.

KPIFormulaSourceTarget
Client-account activation rateactive / provisioned (within 14d)client_account≥ 80%
Per-account MAUmonthly active ClientMembersOBStracked / tenant
PWA install rateinstalls / first-time visitorsRUM≥ 15%
Branded CUO answer ratequeries / MAUOBS≥ 3 / MAU / mo
Branded CUO citation ratecited_answers / totalOBS= 100%
Scoped requests opened / mocountscoped_requesttracked
Scoped request resolution time p95histogramOBS≤ 48 h
Isolation probes failedcross-tenant CI gateCI= 0
DSAR fulfilment p95request → bundle deliveryOBS≤ 30 d (PDPL/GDPR)
Custom-CNAME adoptionclient_account.custom_cname IS NOT NULL countclient_accounttracked / tenant
14

RACI matrix

PORTAL is owned by the CCO seat (customer-facing surface) with CPO for product, CTO for engineering, CSO for isolation.

ActivityCEOCCOCPOCTOCSODPO
Service design + specARRCCC
ImplementationICCA/RCI
Branding policy per clientCA/RCIII
CNAME + ACM cert opsICIA/RCI
SSO setup per clientIRIRAI
Isolation invariant validationIIIRA/RC
DSAR / erasure fulfilment (portal)ICICCA/R
Client-MCP consent policyICRCAC

R Responsible · A Accountable · C Consulted · I Informed.

15

Planned CLI surface

Admin CLI cyberos-portal for tenant CCO operators.

1. Provision a Client Account

$ cyberos-portal account create \
    --name "Acme Corp" --slug acme \
    --owner-email pm@acmecorp.com

[account created]   client_account_id=CA-002104
[default subdomain] clients.cyberskill.cyberos.world/acme
[next steps]        configure SSO + (optional) custom CNAME

2. Apply branding

$ cyberos-portal brand set \
    --account CA-002104 \
    --logo s3://cyberos-tenant-assets/cyberskill/acme-logo.svg \
    --accent "#00A37C" \
    --typography "Inter"

[branding applied]   theme_id=BT-009488
[preview]            clients.cyberskill.cyberos.world/acme (refresh hard-reload)

3. Claim a custom CNAME

$ cyberos-portal cname claim --account CA-002104 --domain portal.acmecorp.com

[challenge issued]
  Add TXT record:
    name:  _cyberos-challenge.portal.acmecorp.com
    value: 9f3a-7b2e-cc14-…
  Wait 5 min then verify:
    cyberos-portal cname verify --account CA-002104

$ cyberos-portal cname verify --account CA-002104
[txt found]    ok
[acm request]  cert_arn=arn:aws:acm:…:cert/…
[live in]      ~ 3 min

4. Configure SSO (SAML)

$ cyberos-portal sso configure-saml \
    --account CA-002104 \
    --idp-metadata-url https://acme.okta.com/.well-known/saml-metadata

[saml configured]
  acs URL:     https://portal.acmecorp.com/portal/sso/saml/acs
  audience:    cyberos-portal-CA-002104
  attribute mapping: email, displayName, role

5. Invite ClientMember (no-SSO fallback)

$ cyberos-portal member invite \
    --account CA-002104 \
    --email cfo@acmecorp.com --role member

[invite sent]   magic link → cfo@acmecorp.com (expires 14d)

6. List scoped requests

$ cyberos-portal requests list --account CA-002104 --status open

SR-009431   2026-05-12   support_ticket   "SOW missing milestone 3"   routed → AM=linh
SR-009447   2026-05-13   billing_inquiry  "Q1 invoice variance"        open

7. Offboard a Client Account

$ cyberos-portal account offboard --account CA-002104 --reason "engagement ended"

[grace period started]   30 days
[exports pre-generated]  dsar bundle queued
[members notified]       5 emails sent
[scheduled wipe]         2026-06-14

8. Verify isolation invariant (CI)

$ cyberos-portal verify-isolation --tenant cyberskill --probes 1000

[probes]     1,000 cross-tenant + 1,000 cross-client-account
[leaks]      0
[passed]     all three layers (router predicate + RLS + scope)
16

Phase status & estimates

Status
Planned
P4 long-term
Est. LoC (Rust)
~6,500
services/portal
Est. LoC (TS)
~5,500
branded Next.js SPA + PWA
Planned tests
110+
incl. isolation property fuzz
External libs
~15
saml-rs · openidconnect · acm sdk
P4 budget
~$120/mo
CDN + edge + multi-region API
CapabilityStatus
Subdomain-based branded portalplanned · P4
Custom CNAME + ACM cert auto-issuanceplanned · P4
SAML 2.0 SSOplanned · P4
OIDC SSOplanned · P4
JIT ClientMember provisioningplanned · P4
Scoped read-only lenses (PROJ / INV / DOC / CHAT)planned · P4
Branded CUO AI assistantplanned · P4
Client-initiated workflows → CHAT threadplanned · P4
PWA + mobile-first responsiveplanned · P4
Vietnamese + English i18nplanned · P4
Multi-currency displayplanned · P4
Self-service DSAR exportplanned · P4
Self-service right-to-erasure (30d grace)planned · P4
External-customer MCP integrationplanned · P4+
Isolation property fuzz in CIplanned · P4
17

References

  • PRD §9.21 — PORTAL — Client portal. FRs 001..005.
  • SRS §7.21 — Client portal subjects + isolation invariants.
  • SRS §4 (FR pending)..005 — Formal requirements with verification methods.
  • Vietnam PDPL (Law 91/2025) — Art. 13 (consent), 14 (DSAR).
  • GDPR (EU 2016/679) — Art. 7 (consent), 15 (access), 17 (erasure).
  • SAML 2.0 Core (OASIS) — assertion + protocol bindings.
  • OpenID Connect Core 1.0 — OIDC SSO surface.
  • OWASP ASVS L2 — Application Security Verification Standard.
  • WCAG 2.1 AA — Web Content Accessibility Guidelines.
  • ISO/IEC 27018 — privacy for PII in public cloud.
  • SOC 2 Type II CC6.x — Logical access & restriction controls.
  • Architecture context: infrastructure.html#portal.
  • AUTH moduleauth.html · SSO + scope foundation.
  • PROJ moduleproj.html · scoped project view.
  • INV moduleinv.html · scoped invoice view.
  • DOC moduledoc.html · scoped signing surface.
  • CHAT modulechat.html · request routing target.
  • TEN moduleten.html · the SaaS-tenant layer above PORTAL.