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.
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.
Logo, colour accents, custom CNAME, branded email templates. The portal looks like an extension of the agency, not a third-party tool.
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.
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.
What it does — 5W1H2C5M
Structured decomposition. Cells trace to PRD §9.21 + SRS §7.21.
| Axis | Question | Answer |
|---|---|---|
| 5W · What | What 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 · Who | Who 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 · When | When does it run? | Continuous. Real-time WebSocket for project status updates and signing-notification deeplinks. Branded email digests on a per-client schedule. |
| 5W · Where | Where 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 · Why | Why 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 · How | How 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 · Cost | Cost 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 · Constraints | Constraints? | (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 · Materials | Stack? | 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 · Methods | Method 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 · Machines | Deployment? | 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 · Manpower | Who maintains? | 0.5 FTE at P4. CCO seat owns product surface; CTO owns brand-theming engine; CSO owns isolation invariants. |
| 5M · Measurement | How measured? | N(FR pending) (zero tenant data leakage), N(FR pending) (TTFB p95 ≤ 500 ms at edge), KPI client-login MAU per tenant. |
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.
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
| Component | Path (planned) | Responsibility |
|---|---|---|
sso.rs | services/portal/src/sso.rs | SAML 2.0 + OIDC callback endpoints. Parses assertion, validates issuer, extracts attributes. |
provision.rs | services/portal/src/provision.rs | Just-in-Time ClientMember provisioning. Creates Subject + ClientMember + ScopeContractGrant on first login. |
lens.rs | services/portal/src/lens.rs | Data-lens narrowing layer. Wraps every federated query with a (tenant_id, client_account_id) predicate; rejects queries that try to escape. |
brand.rs | services/portal/src/brand.rs | Branding config CRUD: logo URL, favicon, colour anchors, custom email-template overrides, CNAME claim + ACM cert issuance. |
cname.rs | services/portal/src/cname.rs | CNAME validation. DNS TXT challenge for ownership verification; ACM cert request via AWS API; automatic renewal. |
theme.rs | services/portal/src/theme.rs | Theme runtime: resolves brand config per request hostname; emits CSS variable bundle in HTML head. |
ai_proxy.rs | services/portal/src/ai_proxy.rs | Branded CUO wrapper. Sets persona to client-cuo with scope narrowed to ClientMember context. |
workflow.rs | services/portal/src/workflow.rs | Client-initiated workflow handlers. New-project request, billing inquiry, support ticket. Materialises CHAT thread on agency side. |
i18n.rs | services/portal/src/i18n.rs | UI-string resolution. Default tenant locale + per-ClientMember override; currency localisation. |
email.rs | services/portal/src/email.rs | Branded transactional email. Per-tenant template overrides; SES via tenant FROM with SPF/DKIM. |
consent.rs | services/portal/src/consent.rs | Client consents (data-processing, marketing, AI usage). Write-allowed for the ClientMember on their own row only. |
dsar.rs | services/portal/src/dsar.rs | DSAR / right-to-erasure surface for the ClientMember's own data; 30-day grace before destructive delete. |
audit_bridge.rs | services/portal/src/audit_bridge.rs | BRAIN 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. |
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.
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).
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"]])
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
| Method | Path | Purpose |
|---|---|---|
| GET | /portal/.well-known/saml-metadata | SAML 2.0 metadata for the client's IdP. |
| POST | /portal/sso/saml/acs | SAML assertion consumer URL. |
| GET | /portal/sso/oidc/callback | OIDC callback. |
| POST | /portal/branding | Update branding theme. CCO scope. |
| POST | /portal/branding/cname | Claim custom CNAME (TXT challenge issued). |
| GET | /portal/branding/cname/{id}/verify | Verify CNAME TXT + request ACM cert. |
| GET | /portal/assets/{theme_id}/logo | Serve logo (CDN-cached). |
| POST | /portal/dsar/export | Generate DSAR bundle. |
| POST | /portal/erasure/confirm | Confirm erasure after 30-day grace. |
MCP tool catalogue (branded CUO)
| Tool name | Inputs | Outputs | Annotations |
|---|---|---|---|
cyberos.portal.my_projects | — | Project[] | readonly · scope=portal.read |
cyberos.portal.project_status | project_id | {milestones, comments} | readonly |
cyberos.portal.invoice_summary | year? | {outstanding, paid, …} | readonly |
cyberos.portal.submit_request | kind, title, body | {ok, request_id} | scope=portal.write_request · human-confirm |
cyberos.portal.grant_consent | consent_type, version | {ok} | scope=portal.write_consent |
cyberos.portal.dsar_request | — | {request_id} | scope=portal.dsar · email-confirm |
cyberos.portal.find_doc | name_match | Document[] | readonly |
Key flows
Flow 1 — Client SSO login (Okta SAML)
Flow 2 — View project (permission-scoped)
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
Flow 4 — Branded client AI assistant (CUO with context)
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
Client-account lifecycle
A ClientAccount traverses four states from provisioning to closed. ClientMember provisioning is JIT on first SSO callback.
Per-state actions
| State | Trigger | Side-effects |
|---|---|---|
| Provisioned | CCO creates ClientAccount | Default subdomain assigned; default branding applied; audit row. |
| Configuring | SSO setup + branding upload | SAML metadata exposed; ACM cert requested (if CNAME). |
| Active | First successful ClientMember SSO callback | Portal usable; data-lens active; AI assistant enabled. |
| Suspended | CCO action | ClientMember login refused; existing JWTs revoked; SSO returns soft "account paused" UI. |
| Offboarding | Tenant or CCO close | ClientMember can still log in for 30 days; banner "account closing on YYYY-MM-DD"; DSAR export tooling surfaced. |
| GracePeriod | Day 1 of 30-day window | Read-only; export bundle pre-generated; emails sent to all ClientMembers. |
| Closed | Day 30 of grace | Data wiped per retention policy; audit row retained; CNAME / ACM cert released. |
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.
Non-Functional Requirements
PORTAL NFRs focus on tenant isolation and edge-served performance.
| NFR ID | Concern | Target | Measurement |
|---|---|---|---|
N(FR pending) | Cross-tenant data leakage incidents | = 0 | property fuzz + annual pen-test |
N(FR pending) | Cross-client-account leakage (within same tenant) | = 0 | RLS verification harness + CI gate |
N(FR pending) | SAML assertion replay | = 0 | NotOnOrAfter + InResponseTo enforced |
N(FR pending) | TTFB p95 (edge) for branded SPA | ≤ 500 ms (global) | RUM + Lighthouse |
N(FR pending) | myProjects GraphQL p95 | ≤ 350 ms | k6 + 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 rate | tracked KPI | OBS |
N(FR pending) | WCAG 2.1 AA compliance | full coverage | axe-core audit |
N(FR pending) | Per-client infra cost | ≤ $20 / mo at typical load | monthly billing |
Dependencies
PORTAL depends on AUTH, BRAIN, and federated subgraphs from PROJ, INV, DOC, CHAT, CRM. AWS ACM + CloudFront for edge TLS + CNAME.
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
Compliance scope
PORTAL is in the SOC 2 / ISO 27001 isolation hotspot and inherits client-data DSAR obligations.
| Regulation / standard | Article / clause | PORTAL feature that satisfies it |
|---|---|---|
| Vietnam PDPL (Law 91/2025) | Art. 14 — DSAR | Client-side DSAR export ((FR pending)). |
| Vietnam PDPL | Art. 13 — Consent | ConsentRecord per ClientMember per consent_type. |
| GDPR (EU 2016/679) | Art. 15 — Right of access | Same surface as PDPL. |
| GDPR | Art. 17 — Right to erasure | Self-service erasure with 30-day grace ((FR pending)). |
| GDPR | Art. 7 — Conditions for consent | Versioned consent doc hash; ConsentRecord captures version. |
| SOC 2 Type II | CC6.1 — Logical access | SAML / OIDC SSO; RBAC; ClientMember role tiers. |
| SOC 2 Type II | CC6.6 — Restricted access | Three-layer predicate-narrowing + RLS + scope check. |
| ISO/IEC 27001:2022 | A.5.30 — ICT readiness for business continuity | Edge CDN failover; multi-region API. |
| ISO/IEC 27018 | Privacy for PII in public cloud | Tenant isolation invariants + per-tenant ACM cert. |
| WCAG 2.1 AA | Accessibility standard | Branded SPA meets AA per axe-core audit. |
| OWASP ASVS L2 | Application Security Verification Standard | SAML/OIDC verification checklist; XSS protection in branded content. |
Risk entries
PORTAL-specific risks in the risk register.
| ID | Risk | Likelihood | Impact | Owner | Mitigation |
|---|---|---|---|---|---|
R-PORTAL-001 | Information disclosure — tenant client sees another tenant's data | Medium | Catastrophic | CSO | Three-layer fail-safe: predicate-narrowing + RLS + scope; CI cross-tenant gate; pen-test pre-launch + annual. |
R-PORTAL-002 | Cross-client-account leak within same tenant | Low | High | CSO | RLS by (tenant_id, client_account_id); GraphQL predicate enforced. |
R-PORTAL-003 | SAML assertion forgery / replay | Low | High | CSO | InResponseTo + NotOnOrAfter; assertion signature validated; relay-state CSRF token. |
R-PORTAL-004 | XSS via branded content (logo URL, accent color) | Medium | Medium | CTO | Strict CSP; logo URL allow-list (s3:// only); colour values validated as hex. |
R-PORTAL-005 | Custom CNAME hijack (subdomain takeover after CNAME drop) | Medium | High | CSO | Active CNAME verification every 24h; alert on TXT drop; auto-suspend on hijack signal. |
R-PORTAL-006 | ClientMember provisioning explosion via SSO attribute spam | Low | Medium | CSO | Rate-limit per (tenant, idp_subject); auto-suspend tenant on abuse threshold; CCO notification. |
R-PORTAL-007 | Branded CUO leaks cross-tenant context via prompt injection | Medium | High | CSO | CaMeL enforcement; persona-stamped JWT cannot escalate; scope-narrowing at AI gateway. |
R-PORTAL-008 | DSAR / erasure abuse — automated bot triggers mass-erasure | Low | Medium | DPO | Email-confirmation step; 30-day grace; tenant CCO can pause if abuse detected. |
R-PORTAL-009 | ACM cert expiry / renewal failure | Low | Medium | CTO | Auto-renewal monitored; OBS alert 14d before expiry; manual override path. |
R-PORTAL-010 | Customer-MCP external-agent over-scope | Medium | Medium | CSO | Per-tool consent gate; scope explicit per tool; revocable; audit trail. |
KPIs
PORTAL health rolls up into 10 KPIs covering activation, isolation, AI usage, and DSAR fulfilment.
| KPI | Formula | Source | Target |
|---|---|---|---|
| Client-account activation rate | active / provisioned (within 14d) | client_account | ≥ 80% |
| Per-account MAU | monthly active ClientMembers | OBS | tracked / tenant |
| PWA install rate | installs / first-time visitors | RUM | ≥ 15% |
| Branded CUO answer rate | queries / MAU | OBS | ≥ 3 / MAU / mo |
| Branded CUO citation rate | cited_answers / total | OBS | = 100% |
| Scoped requests opened / mo | count | scoped_request | tracked |
| Scoped request resolution time p95 | histogram | OBS | ≤ 48 h |
| Isolation probes failed | cross-tenant CI gate | CI | = 0 |
| DSAR fulfilment p95 | request → bundle delivery | OBS | ≤ 30 d (PDPL/GDPR) |
| Custom-CNAME adoption | client_account.custom_cname IS NOT NULL count | client_account | tracked / tenant |
RACI matrix
PORTAL is owned by the CCO seat (customer-facing surface) with CPO for product, CTO for engineering, CSO for isolation.
| Activity | CEO | CCO | CPO | CTO | CSO | DPO |
|---|---|---|---|---|---|---|
| Service design + spec | A | R | R | C | C | C |
| Implementation | I | C | C | A/R | C | I |
| Branding policy per client | C | A/R | C | I | I | I |
| CNAME + ACM cert ops | I | C | I | A/R | C | I |
| SSO setup per client | I | R | I | R | A | I |
| Isolation invariant validation | I | I | I | R | A/R | C |
| DSAR / erasure fulfilment (portal) | I | C | I | C | C | A/R |
| Client-MCP consent policy | I | C | R | C | A | C |
R Responsible · A Accountable · C Consulted · I Informed.
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)
Phase status & estimates
| Capability | Status |
|---|---|
| Subdomain-based branded portal | planned · P4 |
| Custom CNAME + ACM cert auto-issuance | planned · P4 |
| SAML 2.0 SSO | planned · P4 |
| OIDC SSO | planned · P4 |
| JIT ClientMember provisioning | planned · P4 |
| Scoped read-only lenses (PROJ / INV / DOC / CHAT) | planned · P4 |
| Branded CUO AI assistant | planned · P4 |
| Client-initiated workflows → CHAT thread | planned · P4 |
| PWA + mobile-first responsive | planned · P4 |
| Vietnamese + English i18n | planned · P4 |
| Multi-currency display | planned · P4 |
| Self-service DSAR export | planned · P4 |
| Self-service right-to-erasure (30d grace) | planned · P4 |
| External-customer MCP integration | planned · P4+ |
| Isolation property fuzz in CI | planned · P4 |
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 module — auth.html · SSO + scope foundation.
- PROJ module — proj.html · scoped project view.
- INV module — inv.html · scoped invoice view.
- DOC module — doc.html · scoped signing surface.
- CHAT module — chat.html · request routing target.
- TEN module — ten.html · the SaaS-tenant layer above PORTAL.