ADR 0002: API control plane
Date: 2026-09-09. Status: accepted (decisions taken at the recommended option; overturn by editing this file and the code together).
Context
The API is the only component that knows users. It fronts the operator's CRDs for the dashboard and CLI, authenticates against the customer's IdP, enforces the three roles, validates the offline licence, keeps the audit log and brokers terminal sessions. It runs inside the customer's cluster and must work air-gapped.
Decisions
- Embedded SQLite, pure Go driver, one file on a PVC. No database server ships or is required. The audit table refuses UPDATE and DELETE by trigger, so append-only is a database guarantee. Everything stays inside the cluster.
- Spec first.
api/openapi/openapi.yamlis the contract; a test fails when a served route is missing from the spec or a spec path is not served. The web step generates its client from this file. - Errors are message keys with parameters, never prose:
{code, params, causes[]}. Operator admission causes pass through unchanged.api/pkg/i18nholds the English and Arabic texts for every key, merged with the operator catalog, and a test requires both languages for each key. - Identity comes from the IdP. OIDC authorization code with PKCE (go-oidc) and SAML SP-initiated with static IdP metadata (crewjam/saml). Both mint a Nazeel session token signed with EdDSA; the key is generated at first run and kept in the data directory. Roles are never embedded in the token: they are re-read per request, so revocation and role changes are immediate. Group-to-role mapping is admin configuration.
- Bootstrap admin token for the first-run wizard only. Once SSO is configured and the API restarts, the token is ignored even if still passed.
- RBAC is a code matrix (
internal/rbac) evaluated in handlers; the UI hides what it forbids. Developer: own environments fully, team read-only. Team lead: plus team environments, quota, members, reports. Admin: everything. Terminal is owner-only for every role. Sharers get read and logs on the shared environment. - Licence (
api/pkg/licence):NZL2.+ base64url(JSON) +.+ base64url(ed25519 signature), verified against vendor public keys compiled in (plusNAZEEL_VENDOR_KEYS). States valid → grace (30 days, warning) → expired. Expired or missing blocks creating environments and installing features; it never deletes anything. Seats are counted as users active in the last 30 days; a new identity is refused when seats are full, existing users always get in. Vendor-side issuing tool:nazeel-licence(not shipped in images). The development vendor key and licence inhack/must never sign a customer licence. - Audit: every entry hashes
sha256(canonical fields + prev_hash).GET /audit/verifywalks the chain. Evidence export is JSON lines plus a manifest signed with a per-install ed25519 key whose public half is served at/audit/public-key. SIEM forwarding to syslog (RFC 5424, octet-counted over TCP) or HTTP JSON runs off a bounded retry queue; the local log is the source of truth and is never blocked by the collector. - Terminal: WebSocket to pod exec, restricted to the caller's own environment
namespace (never the baseline), gated by Policy
terminalEnabled, the admin per-team switch and the licence feature. Every session is recorded as asciicast v2 with input and output, listed for admins, and audited at open and close with byte count and duration. - Dashboard is served by the API from a static directory with a strict CSP, so no extra web server image is needed.
- No outbound calls except the customer's IdP (OIDC discovery and token exchange, SAML redirects), the customer's SIEM, and the cluster API server.
Consequences
- One container image for API and dashboard; one PVC for state. Backups mean copying the data directory.
- SQLite limits the API to one writer replica. Horizontal scaling is a later concern and would introduce Postgres as an option.
- Cross-namespace team quotas, TTL extension permissions and preview-URL SSO are enforced here, as the operator handover required.