docs: add portal docs

docs: update architecture decision records and interface contracts to clarify edgeCloudProvider mapping and topology hiding rules

Merge request reports

Loading
+1 −1
Changes for decisions/0048-platform-roles.md: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -9,7 +9,7 @@ status: "proposed"

# ADR-0048: Three platform roles, API product grants, and tenancy

**Status:** proposed
**Status:** proposed — decision 6's "`app_provider_id` is the token's `sub`" is amended by [ADR-0066](/decisions/0066-api-clients-through-keycloak-client-registration.md): the `app_provider_id` claim when present, else `sub`
**Date:** 2026-10-04
**Affects:** [platform/architecture.md](/platform/architecture.md) §Security Architecture → Roles, [oeg/architecture.md](/oeg/architecture.md) (role and scope checks, F4), [oeg/persistence-model.md](/oeg/persistence-model.md) (`tenant_id`, `app_provider_id`, `api_subscriptions`), [platform/glossary.md](/platform/glossary.md), [ADR-0006](/decisions/0006-oeg-db-schema-consolidation.md), [ADR-0035](/decisions/0035-qod-session-service-specification.md), portal backend `Role` enum, Helm `charts/keycloak` (realm `oop`)

+56 −0
Changes for decisions/0064-portal-is-one-container-with-a-thin-backend.md: 56 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0064: The portal is one container: a FastAPI backend that forwards calls and serves the built React app; no Next.js server"
description: "The portal ships as one image. A Python/FastAPI backend serves the Vite-built React app at / and its own API at /api/*, so browser and API share one address. The backend forwards calls to OEG, Keycloak and AI² and merges nothing."
tags: [adr, portal, bff]
timestamp: 2026-10-08T00:00:00Z
status: "proposed"
---

# ADR-0064: The portal is one container with a thin backend

**Status:** proposed
**Date:** 2026-10-08
**Affects:** [platform/architecture.md](/platform/architecture.md) (§Portal, Container Topology, Docker Compose Profiles, Scaling Strategy, Tradeoffs), [portal/overview-and-architecture.md](/portal/overview-and-architecture.md)

## Context

[platform/architecture.md](/platform/architecture.md) describes the portal as a Next.js app with
a backend-for-frontend that stitches several backend APIs together. None of that
was built:

- The GUI team moved the frontend from Next.js to a Vite React template.
- The backend that was built is Python/FastAPI, like OEG, SRM and FM.
- Every portal page reads from one API at a time, mostly OEG. No page needs data merged from
  several backends.

The GUI calls the API with relative URLs, so with the app and its API on one address the login
cookies need no CORS and no cross-origin requests. In development Vite proxies `/api` to the backend. In the
cluster there is no Vite and no ingress yet (Nginx is Phase 2+, [oeg/architecture.md](/oeg/architecture.md)).

## Decision

1. The portal ships as **one image**: the Vite-built React app and the FastAPI backend. The
   backend serves the app at `/` (unknown paths get `index.html`) and its API at `/api/*`.
2. The backend is **thin**. It logs users in, keeps their tokens, creates API clients, and
   forwards calls to OEG and AI² as the user.
3. In development the two halves run apart: Vite serves the app and proxies `/api` to the
   backend.

## Consequences

What becomes easier:

- One image and one chart; the login cookies work without an ingress.
- The backend uses the same stack and checks as OEG and SRM (FastAPI, uv, ruff, mypy,
  import-linter).
- OEG's own OpenAPI document drives the catalog and "try it", with no second schema to keep.

Deferred or excluded:

- Merging data from several backends.

Revisit trigger:

- A page that needs data from several backends in one call, or a platform ingress that could
  serve the GUI on its own.
+72 −0
Changes for decisions/0065-portal-sessions-keyed-by-the-cookie.md: 72 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0065: Portal login sessions live in PostgreSQL, encrypted with a per-session key that only the browser's cookie holds"
description: "The portal backend keeps each user's Keycloak tokens in a login_sessions row named by the hash of a session id and encrypted with AES-256-GCM under a random key. The cookie carries the id and the key; the server keeps neither. Login attempts are single-use rows. Sessions end when Keycloak stops accepting their refresh token."
tags: [adr, portal, security, keycloak]
timestamp: 2026-10-08T00:00:00Z
status: "proposed"
---

# ADR-0065: Portal sessions are keyed by the cookie

**Status:** proposed
**Date:** 2026-10-08
**Affects:** [portal/requirements-and-design.md](/portal/requirements-and-design.md) §J, [platform/architecture.md](/platform/architecture.md) (§Portal, BFF Token Handler)

## Context

The browser must never receive a token ([platform/architecture.md](/platform/architecture.md)),
so the portal backend keeps each user's access, refresh and ID tokens between requests. Two
questions follow: where they live, and how they are protected.

Where: the backend could keep them in memory (lost on every restart, one replica only), in Redis
(the Helm chart has none), or in PostgreSQL (already deployed).

How:

- **Plain tokens in the database**, the default of Spring Security's and Auth.js's stores. A
  copy of the database is a set of usable tokens.
- **One app-wide key**, as Duende BFF and ASP.NET Data Protection do. The key must be stored,
  rotated and kept apart from the database; together they open every session.
- **A per-session key in the cookie**, as oauth2-proxy does. The database alone opens nothing,
  and one cookie opens only its own session.

The values of a login in flight (`state`, `nonce`, PKCE verifier) also have to survive the trip
to Keycloak: in a short cookie, or in a database row.

## Decision

1. **Sessions live in PostgreSQL** (`login_sessions`).
2. **The cookie carries a random session id and a random 256-bit key.** The row is named by the
   SHA-256 of the id and holds the tokens encrypted with AES-256-GCM under that key. The server
   stores neither the id nor the key. There is no app-wide key.
3. **Login attempts are database rows**, named by the hash of an id in a short cookie, read and
   deleted in one statement, and valid for 5 minutes.
4. **A session lasts as long as Keycloak accepts its refresh token.** Refreshing happens under a
   row lock. A refused refresh deletes the session; an unreachable Keycloak is `503`, not `401`.

## Consequences

What becomes easier:

- A stolen database, or a backup, exposes no token and no cookie.
- There is no key to store, rotate or lose.
- Sessions survive restarts and work across several replicas.
- Disabling a user in Keycloak ends their portal session at the next refresh.

What becomes harder:

- The server cannot open a session without its cookie, so it cannot act for a user in the
  background. Data that background work needs uses an app-wide key instead (API client
  registration tokens, [ADR-0066](/decisions/0066-api-clients-through-keycloak-client-registration.md);
  OEG's callback tokens, [ADR-0063](/decisions/0063-callback-tokens-encrypted-in-oeg-db.md)).
- Revocation waits for the next refresh, up to one access token lifetime (5 minutes).

Deferred or excluded:

- Ending a user's sessions at once. When the portal gets user administration, it can delete
  that user's rows (they are indexed by subject).

Revisit trigger:

- The portal needs to call OEG for a user while they are not making a request.
+86 −0
Changes for decisions/0066-api-clients-through-keycloak-client-registration.md: 86 added lines, 0 removed lines.
Original line number Diff line number Diff line
---
type: Decision Record
title: "ADR-0066: Developers' API clients are Keycloak clients the portal registers through Keycloak's client registration service; the portal does not reconcile them with Keycloak in Release 2"
description: "The portal creates a confidential, client-credentials-only Keycloak client per API client, with a provisioner that can register clients and map the product roles but cannot read or change other clients, except reading `oeg`. Products are fixed at creation and limited to the creator's grants. The secret is shown once. Each client is managed through its own registration token, stored encrypted under one app-wide key. A refused token on delete is reported, not hidden. Nothing keeps the portal's records and Keycloak in sync in Release 2."
tags: [adr, portal, keycloak, api-clients, security]
timestamp: 2026-10-08T00:00:00Z
status: "proposed"
---

# ADR-0066: API clients through Keycloak client registration

**Status:** proposed
**Date:** 2026-10-08
**Affects:** [platform/architecture.md](/platform/architecture.md) (UC-OOP-08, §Security Architecture), [portal/requirements-and-design.md](/portal/requirements-and-design.md) §L, Helm `charts/keycloak` (realm `oop`) and `charts/portal`, builds on [ADR-0048](/decisions/0048-platform-roles.md) and amends its decision 6 (`app_provider_id`), [oeg/architecture.md](/oeg/architecture.md) F4, [oeg/persistence-model.md](/oeg/persistence-model.md)

## Context

UC-OOP-08 says a developer gets OAuth2 client credentials for their own software, with the portal
calling "OEG/CAPIF for credential generation". OOP has no CAPIF. Keycloak issues every platform
token, and OEG grants access by token scope, gated per API product (ADR-0048).

The portal needs to create clients that carry only their products' scopes, without holding
rights over every other client: Keycloak's `view-clients` exposes every client's secret, and
`manage-clients` could change the portal's own redirect URLs.

## Decision

1. **The provisioner** (`oop-portal-provisioner`) has `create-client`, `view-users` and
   `query-users`, a fine-grained permission to map only the product roles, and read access to
   the `oeg` client only. It never has `view-clients`, `manage-clients` or `manage-users`.
   Keycloak's registration policies limit the scopes and mapper types a new client may carry.
2. **An API client** is a confidential Keycloak client that can only use client credentials. It
   carries the scopes of its products and the `oeg` audience, its service account holds the
   product roles, and its tokens carry `organization` (the creator's organization, or their
   `sub`) and `app_provider_id` (the creator's `sub`). OEG takes the caller's `app_provider_id`
   from this claim when present, else from `sub`, so what an API client creates belongs to its
   creator.
3. **Products are fixed at creation** and limited to those the creator holds in Keycloak. Each
   user has at most 5 clients, with unique names.
4. **The secret is generated by Keycloak, shown once** and never stored by the portal.
5. **The portal's database records ownership and products.** Each client's registration token is
   stored AES-256-GCM encrypted under one app-wide key, because the client outlives the session
   that created it ([ADR-0065](/decisions/0065-portal-sessions-keyed-by-the-cookie.md) keys
   cannot open it later).
6. **Delete uses the client's own registration token.** If Keycloak refuses it, the portal answers
   `503` and changes nothing. The client may only be disabled, and re-enabling it would bring its
   old secret back, so the portal does not report it deleted.
7. **No reconciliation in Release 2.** The portal does not compare its records with Keycloak. An
   operator keeps Keycloak in order by hand, following the how-to in the Keycloak chart README,
   and the portal never says that clients lose access on their own.

## Consequences

What becomes easier:

- A compromised portal backend cannot change existing clients, the portal's login included, or
  read their secrets, except `oeg`'s.
- API client tokens are ordinary Keycloak tokens that OEG already checks.
- The Keycloak calls a later sync needs (`read_scopes`, `set_scopes`, `set_secret`, `revoke`)
  are already built, unit-tested, and checked by hand against a real Keycloak.

What becomes harder:

- A compromised portal backend can still register any client the registration policies allow,
  including one with a browser login (no Trusted Hosts policy is set), and give product roles to
  any user, not only to service accounts. It can also read the `oeg` client's secret.
- **A disabled or deleted developer's API clients keep working** until an operator deletes them:
  client credentials never check the person. A product taken from a developer stays on their
  clients until an operator removes it.
- The portal's list does not show what operators change in the Keycloak console. A client an
  operator deleted there cannot be deleted from the portal.
- Losing the app-wide key makes every stored registration token useless, so no client can be
  deleted from the portal.
- If the database commit fails after a client was registered, the Keycloak client is left behind
  without a log line. Two quick creates can pass the per-user limit together.

Deferred or excluded:

- The reconciliation job, secret rotation, disabling and enabling, changing products, operator
  approvals in the portal, ownership by organization instead of by creator, and a TMF931-shaped
  API.

Revisit trigger:

- Before the platform is opened to many developers: the reconciliation job comes first.
- Ownership moving to the organization, which also ends the interim `app_provider_id` claim.
+3 −0
Changes for decisions/index.md: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -59,3 +59,6 @@ the reasoning lives here. Never renumber; supersede instead (see the record-deci
* [ADR-0061](0061-use-short-execution-action-vocabulary.md) - proposed five-action vocabulary shared by the public SDK methods and supported_actions, with an explicit SRM intent mapping
* [ADR-0062](0062-zone-state-uses-camara-inactive.md) - `zone.state` uses CAMARA's `active`/`inactive`/`unknown` (was `offline`), so OEG passes the zone status filter and value through unmapped; domain, capability and control-path-binding states keep `offline`
* [ADR-0063](0063-callback-tokens-encrypted-in-oeg-db.md) - client callback tokens (EAM sinkCredential, QoD notificationAuthToken) are stored AES-GCM encrypted in oeg_db under one app-wide key; no key → 501; expired/revoked tokens are tombstoned and their deliveries skipped, never sent unauthenticated (amends ADR-0006's sink_credential_ref)
* [ADR-0064](0064-portal-is-one-container-with-a-thin-backend.md) - the portal is one image: a FastAPI backend serves the built React app at `/` and its API at `/api/*`, and forwards calls without merging data; no Next.js server
* [ADR-0065](0065-portal-sessions-keyed-by-the-cookie.md) - portal login sessions live in PostgreSQL, tokens encrypted with a per-session key only the cookie holds; login attempts are single-use rows; a session lasts as long as Keycloak accepts its refresh token
* [ADR-0066](0066-api-clients-through-keycloak-client-registration.md) - developers' API clients are Keycloak clients registered by a narrow provisioner, products fixed at creation within the creator's grants, secret shown once, registration token encrypted under one app-wide key; a refused token on delete is reported; no reconciliation with Keycloak in Release 2
Loading
Loading