Changes for portal/overview-and-architecture.md: 3 added lines, 3 removed lines.
Original line number
Diff line number
Diff line
@@ -79,7 +79,7 @@ browser.
| Roles | The three realm roles; the portal hides what a role may not use | [ADR-0048](/decisions/0048-platform-roles.md) |
| API access | OEG checks every call; the portal never decides on OEG's behalf | [ADR-0048](/decisions/0048-platform-roles.md) |
| API clients | Registered in Keycloak by the portal, for the products the developer already holds | [ADR-0066](/decisions/0066-api-clients-through-keycloak-client-registration.md) |
| Federation | Operator pages over OEG's federation API; the portal never calls FM | [ADR-0043](/decisions/0043-oeg-proxied-federation-establishment.md) |
| Federation | Operator pages over OEG's federation API; the portal never calls FM | — |
| **Portal backend** | The FastAPI service. It is the only part that talks to OEG, Keycloak and AI². Elsewhere called the BFF (Backend for Frontend) |
| **Login session** | One logged-in browser: a cookie, plus a database row with that browser's tokens, encrypted |
| **API product** | One CAMARA API offered as a unit, with its scopes ([glossary](/platform/glossary.md)) |
| **API product** | What OEG offers and an application is entitled to ([glossary](/platform/glossary.md)) |
| **Product grant** | Permission to use one API product ([glossary](/platform/glossary.md)) |
| **API client** | Credentials a developer creates in the portal so their own software can call OEG. A Keycloak confidential client with its own secret |
| **Provisioner** | The Keycloak client the backend uses to create API clients. It has only the rights that needs |
| **Provisioner** | The Keycloak client the backend uses to create API clients. It has only the rights it needs |
| REQ-PORTAL-01 | Users log in and sign up on Keycloak's own pages (OpenID Connect authorization code flow with PKCE). The portal never handles a password. |
| REQ-PORTAL-02 | The browser never receives an access, refresh or ID token. It gets cookies, and the user's public details from `/api/auth/me`. |
| REQ-PORTAL-03 | Pages and menu items follow the user's realm roles. The backend checks `app-provider` on the API client routes. |
| REQ-PORTAL-04 | Every platform action is a call to OEG with the user's own access token. The portal adds no access rules of its own. |
| REQ-PORTAL-05 | A session ends when Keycloak stops accepting its refresh token. Logging out ends the Keycloak session too. |
| REQ-PORTAL-06 | A developer creates API clients only for products they hold, up to a per-user limit, with names unique per user. The secret is shown once and never stored. |
| REQ-PORTAL-07 | Deleting an API client removes it from Keycloak. The portal never reports a delete that did not happen in Keycloak. |
| REQ-PORTAL-08 | The API catalog is built from OEG's `openapi.json`. Only operators see the federation API. |
| REQ-PORTAL-09 | The AI² agent's address is backend configuration. The browser does not know which model runs behind it. |
| REQ-PORTAL-10 | With login off (`AUTH__SECURITY_MODE=none`, local development only) every request acts as a development user with every role and product, and API clients are unavailable. |
| REQ-PORTAL-11 | The portal's own API answers errors as RFC 7807 problem documents. |
---
## I. Backend Routes
Everything under `/api/*` is the backend; every other path is the React app.
| Method | Path | Needs | Does |
|---|---|---|---|
| `GET` | `/api/auth/login?return_to=` | — | Starts a login and redirects to Keycloak |
| `GET` | `/api/auth/callback` | — | Finishes the login, sets the session cookie, redirects to `return_to` |
The API client routes answer `404` when provisioning is not configured or login is off.
---
## J. Login and Sessions
Why the tokens are kept this way: [ADR-0065](/decisions/0065-portal-sessions-keyed-by-the-cookie.md).
### J.1 Login
1. The app sends the browser to `/api/auth/login?return_to=<page>`. A `return_to` that is not a
portal path becomes `/`.
2. The backend creates a `state`, a `nonce` and a PKCE verifier, and saves them in a
`login_attempts` row. The browser gets a short cookie naming that row (5 minutes, path
`/api/auth`, one per login so two tabs do not clash), then goes to Keycloak.
3. The user logs in or signs up on Keycloak. New users get `app-provider` there.
4. Keycloak sends the browser to `/api/auth/callback` with a code and the `state`.
5. The backend reads and deletes the attempt in one statement, so a callback works once. It
checks the `state`, trades the code for tokens with the verifier, and checks the ID token:
signature, issuer, audience, `typ`, and the `nonce`.
6. It opens a session, sets the session cookie and sends the browser to `return_to`.
A failed login sends the browser back to the app with `?error=cancelled|expired|failed|unavailable`.
The exact reason is only logged, so a caller learns nothing about which check failed.
### J.2 What the session keeps
| Where | What |
|---|---|
| Cookie `oop_session` | `<session id>.<key>`: a random id and a random 256-bit key. `HttpOnly`, `SameSite=Lax`, `Secure` over HTTPS |
| `login_sessions` row | The SHA-256 of the session id, and the access, refresh and ID tokens encrypted with AES-256-GCM under the cookie's key |
### J.3 Each request, refresh and revocation
- Every route that acts for a user opens the session from the cookie. `/api/auth/me` and the API
client routes also check the access token; `/api/oeg/*` and `/api/ai2/chat` forward it and
leave the check to OEG and AI².
- 30 seconds before the access token expires, the backend refreshes it. The row is locked
while refreshing, so the several requests of one page load refresh once, and this keeps
working if Keycloak's refresh-token rotation is turned on.
- The session lasts as long as Keycloak accepts its refresh token (realm settings: 30 minutes
idle, 10 hours at most).
-**Revocation works through refresh.** Disabling a user or ending their session in Keycloak
makes the next refresh fail; a password reset is assumed to do the same
([overview E3](/portal/overview-and-architecture.md)). The portal then deletes the session and
answers `401`. An access token already issued stays valid until it expires (5 minutes).
- Keycloak unreachable while refreshing is `503`, not `401`: the session may be fine.
### J.4 Logout
`POST /api/auth/logout` deletes the row and the cookie, and returns Keycloak's logout URL (with
the ID token as hint). The app then sends the browser there, so the Keycloak session ends too.
Logout never refreshes, so it works while Keycloak is down. It is a `POST` so a link or image on
another site cannot trigger it.
### J.5 Around the session
-**Cross-site writes.**`SameSite=Lax` keeps the cookie off most cross-site requests. The
backend also refuses any write to `/api/oeg/*` and `/api/ai2/chat` whose `Origin` is not the
portal's.
-**Housekeeping.** Every 5 minutes the backend deletes expired sessions and abandoned login
attempts. Both are already refused when read; this only keeps the tables small.
-**In the app.** Any `401` from the backend means the session is gone: the app sends the user
to log in and back to the same page. If a login does not stick, the app stops after one
redirect instead of looping.
-**Login off.** With `AUTH__SECURITY_MODE=none` there is no login and no session. Every request
acts as a development user, and calls reach OEG and AI² without a token.
---
## K. Forwarding to OEG and AI²
**OEG.**`/api/oeg/{path}` goes to `{OEG__BASE_URL}/{path}` with the user's access token.
| Rule | Why |
|---|---|
| Only these headers pass. Both ways: `content-type`, `x-correlator`. In only: `accept`, `accept-language`. Out only: `location`, `retry-after` | The cookie must not reach OEG, and OEG's connection headers mean nothing to the browser |
| OEG's answers pass through unchanged, error bodies included | The app shows OEG's own CAMARA (or, for federation, GSMA) error messages |
| OEG answering `401` becomes `502 OEG did not accept the credentials` | It is a setup problem, not the end of the session; a `401` would log the user out |
| OEG unreachable is `502`; no answer in time is `504` | |
| A `Location` pointing at OEG is rewritten to `/api/oeg/...` | The browser stays on the portal |
| The database connection is released before the call | A slow OEG must not hold database connections |
The app uses this route for the catalog (`/api/oeg/openapi.json`), for "try it" (Swagger
requests are rewritten to go through it), for Edge Application Management
(`/edge-application-management/v0.1alpha1/...`) and for the Federation tab
(`/platform/v1/federation/...`).
**AI².**`POST /api/ai2/chat` goes to `AI2__CHAT_URL` with the same token and the body unchanged
(`{query, session_id}`). The same `401`, `502` and `504` rules apply. The list of conversations
lives only in the browser tab and is lost on reload; each message carries its conversation's
`session_id`, so AI² may keep context on its side.
---
## L. API Clients
Why they are built this way: [ADR-0066](/decisions/0066-api-clients-through-keycloak-client-registration.md).
### L.1 Who holds what
| Where | What |
|---|---|
| Keycloak | The client and its secret, its scopes, the product roles on its service account, and the developer's own product grants |
| Portal database | Who created it, its owner, its registration token (encrypted), and the history of its products |
| Browser | The secret, once |
### L.2 Rules
- Only `app-provider` users have API clients.
- At most 5 per user (`PROVISIONING__MAX_CLIENTS_PER_CREATOR`), names unique per user among
clients not deleted.
- The creator must exist and be enabled in Keycloak.
- Every product asked for must be one the creator holds, including through groups. Products are
fixed at creation.
- The client can only use client credentials (no browser login). Its tokens carry the `oeg`
audience, the scopes of its products, `organization` (the creator's organization, or their
`sub`) and `app_provider_id` (the creator's `sub`).
- Keycloak generates the secret. The create answer carries it with `Cache-Control: no-store`;
the portal never stores or logs it.
### L.3 Create
1. Check the limit and the name.
2. Read the creator from Keycloak: enabled, organization, effective product roles.
3. Refuse products the creator does not hold (`403`, naming them).
4. Register the client in Keycloak with the provisioner's token. Keycloak returns the secret and
a registration token for that one client.
5. Give the client's service account the role of each product.
6. Save the row, with the registration token encrypted, and one `approved` entitlement per
product.
7. Return the secret.
If step 5 or 6 fails, the portal deletes the Keycloak client again. If that fails too, it logs
`orphan keycloak client`. If the database commit at the end of the request fails, the Keycloak
client stays and nothing is logged.
### L.4 Delete
1. An unknown client, someone else's, or one already deleted all get the same `404`.
2. Delete the Keycloak client with its own registration token. Its service account and product
roles go with it.
3. Mark its entitlements `revoked` and set `deleted_at`. The row stays as a record; the name is
free again.
**Keycloak refuses the registration token.** An operator deleted or disabled the client in the
Keycloak console; Keycloak does not say which. The portal answers `503` ("Ask an administrator to
check it") and changes nothing. Saying "deleted" would be wrong for a disabled client: it still
exists in Keycloak, and re-enabling it would bring its old secret back.
### L.5 What the portal does not keep in sync
The portal does not compare its database with Keycloak. Its list comes from its own database, so
it does not notice what an operator does in the console. Until that is automated (see
[implementation-and-delivery.md §U](/portal/implementation-and-delivery.md)), an operator keeps
things in order in Keycloak, following the how-to in the Keycloak chart README.
| What happens | Without the portal noticing | Operator does |
|---|---|---|
| A developer is disabled or deleted | Their API clients **keep working**: client credentials never check the person | Delete their clients in Keycloak |
| A developer loses a product | Their clients keep it | Remove that role from each client's service account |
| A developer changes organization | Their clients keep writing into the old organization's data | Delete their clients |
| An operator deletes or disables a client | The portal still lists it, and deleting it there answers `503` | Prefer deleting over disabling |
| A product role is added to a client by hand | The client gets the scopes; the portal does not show it | Do not add roles by hand; the developer creates a new client |
The portal never says that clients lose access on their own.
---
## M. Data Model
PostgreSQL database `portal`. Tables are created at start-up when missing; there are no