Estate portal & revoked-scopes baseline¶
Complete reference for the estate action surface (the OIDC-gated part of the evidence & estate API), the Backstage estate portal that consumes it, and the revoked-scopes GitOps policy baseline that drives platform-wide scope revocation. For the state machine these actions and policies drive, see app lifecycle; for the read-only estate/evidence surface see evidence & estate API.
Sources of truth: specs/005-lifecycle-estate/contracts/estate-api.md,
specs/005-lifecycle-estate/contracts/backstage-catalog.md,
specs/005-lifecycle-estate/contracts/revoked-scopes.md,
services/evidence-estate-api/, backstage/, and
deploy/policies/revoked-scopes-configmap.yaml.
Estate actions (evidence-estate-api)¶
Base URL (in-cluster): http://evidence-estate-api.tend-system.svc:8080.
The action surface is a thin, UI-agnostic layer: any equally-authenticated
client can drive the same operations the portal does (spec FR-018).
GET /estate/{app}/lifecycle
POST /estate/{app}/actions/suspend
POST /estate/{app}/actions/resume
POST /estate/{app}/actions/retire
POST /estate/{app}/actions/restore
GET /estate/{app}/lifecycle¶
Returns the app's durable lifecycle history (status.lifecycle.history —
type, cause, actor, timestamp) plus best-effort enrichment from any
still-live Kubernetes Events for the App object. History is the record;
Event enrichment is optional context and a listing failure degrades to
history-only, never a 5xx — see
durable history vs. transient Events.
{
"app": "demo-app",
"history": [
{ "type": "ownerless", "cause": "ownerless", "at": "2026-07-13T08:00:00Z" },
{ "type": "frozen", "cause": "ownerless", "at": "2026-07-13T08:02:00Z" },
{ "type": "suspended", "actor": "bob", "at": "2026-07-13T09:15:00Z" }
],
"events": [
{ "reason": "AppManuallySuspended", "message": "…", "at": "2026-07-13T09:15:00Z", "count": 1 }
]
}
POST /estate/{app}/actions/{action}¶
| Property | Detail |
|---|---|
| Auth | Bearer token, Keycloak OIDC. 401 unauthenticated (missing/malformed/expired/wrong-issuer/wrong-audience/bad-signature — the response body never says which); 403 authenticated but not in the required group. There is no unauthenticated action path — an unconfigured verifier (OIDC_ISSUER unset) disables the whole action surface with 503, leaving the read surface unaffected. |
| Actor | The token's preferred_username claim, never anything from the request body — the server stamps the actor, callers cannot spoof it. |
| Execution | Patches the corresponding tendtech.io/<action> annotation onto the App CR with the actor as its value; the operator (the single lifecycle authority) consumes and executes it. The API's RBAC on Apps is get/list/watch/patch only — no delete. |
| Idempotency | Repeating an action already pending is a no-op (the annotation is already set to that value). |
| Success | 202 Accepted, {"app", "action", "actor", "acceptedAt"}. |
| Conflict | 409 — one authoritative outcome per the estate-action race edge case (see table below). |
resume lifts a suspension only (manual or cost) — it never clears a
freeze; freezes clear exclusively by clearing their causes (spec
FR-003/FR-017). Renewal is deliberately not an estate action: it is the
owner's action (the tendtech.io/renew annotation, or an expiry edit), not
a platform-engineer one.
| Action | 409 when |
|---|---|
suspend |
app is retired (a tombstone cannot be suspended) |
resume |
app is retired, or the app is not currently suspended (status.suspension is nil) — resume lifts suspensions only |
retire |
app is already retired |
restore |
app is not retired — only a retired app restores from its archive |
Auth configuration¶
| Env | Default | Meaning |
|---|---|---|
OIDC_ISSUER |
— | Keycloak realm issuer. Unset disables the action surface (503); the read surface is unaffected. |
OIDC_AUDIENCE |
tend-estate-api |
Required aud claim — the audience the tend-backstage client maps onto its tokens. |
ESTATE_ACTIONS_GROUP |
/platform-engineers |
The realm group required to call any action endpoint; matched with or without the leading slash. |
Backstage estate portal¶
The portal (Clarification 2026-07-12: portal-with-catalog, not a minimal
plugin shell) is a consumer of the estate API — never of the Kubernetes
API, and never a second source of truth (Constitution IV). Deleting the
Backstage deployment loses only rebuildable projection state (its SQLite
catalog); the minimal standalone estate viewer (estate-viewer/, see
evidence & estate API) from the walking skeleton remains a
functioning fallback of the identical contract (risk 5).
Catalog provider contract¶
TendEstateEntityProvider (backstage/packages/backend/src/modules/tendEstateProvider/)
is a custom backend module and the only writer of Tend app entities in
the catalog:
- Polls
GET /estateon a scheduled task, default every 60 seconds (tendEstate.pollSeconds, config-driven, kept ≤ the lifecycle reconcile interval per FR-016/FR-020). - Every successful poll performs a full mutation (replace-all) of its
entity set — one
Componentper app,metadata.name= app name,spec.owner= the app's owner,spec.lifecycle= the estateenvironmentvalue (sandbox | promotion-pending | production | frozen | retired). - A failed poll (network error, non-2xx, malformed body) logs a warning and leaves the previous entity set untouched — it never crashes the backend and never mutates the catalog on a failed fetch.
- Retired apps stay in the catalog as tombstones (lifecycle
retired); an app absent from/estateentirely is removed. No manual registration path exists for Tend apps.
| Config | Default | Meaning |
|---|---|---|
tendEstate.baseUrl |
http://evidence-estate-api.tend-system.svc:8080 |
The estate API base URL the provider polls. |
tendEstate.pollSeconds |
60 |
Poll cadence for the full-mutation refresh. |
tendtech.io/* catalog annotations¶
Estate fields travel as metadata.annotations on each Component, so the
same estate data the portal table shows is also queryable through the
standard catalog API:
tendtech.io/owner-state, tendtech.io/org-unit, tendtech.io/expiry,
tendtech.io/data-scopes (JSON), tendtech.io/revoked-scopes (JSON),
tendtech.io/cost-accrued, tendtech.io/cost-state,
tendtech.io/freeze-causes (JSON), tendtech.io/ai-act-triage,
tendtech.io/archive-ref.
Estate plugin (frontend)¶
Renders the estate table (owner, ownerless flag, environment, data scopes,
expiry, accumulated cost, freeze/suspension posture, and the AI Act triage
marker — spec FR-015) and the per-app lifecycle-history view
(GET /estate/{app}/lifecycle). Suspend/resume/retire/restore each execute
as a single action, passing the signed-in user's own token — the estate API
is the sole authorization point; the plugin disables actions the current
state visibly forbids but treats API 409s as authoritative rather than
trusting its own client-side state.
Sign-in¶
Keycloak OIDC via the confidential client tend-backstage (standard flow,
in-cluster redirect URIs only); an audience mapper on that client is what
lets portal-issued tokens satisfy the estate API's OIDC_AUDIENCE check
above. Estate actions require the platform-engineers realm group,
enforced server-side by the estate API — the portal's own group display is
cosmetic, not an authorization boundary.
Deployment facts¶
| Property | Value |
|---|---|
| Namespace | tend-system |
| Deployment | backstage, 1 replica, image harbor.tend-system.svc/platform/backstage:latest, port 7007 |
| Service | backstage.tend-system.svc:7007 (ClusterIP only — no Ingress off-boundary; reached the same way the estate viewer is today) |
| Storage | SQLite in-pod (no external database — no new stateful dependency this slice) |
| ServiceAccount | backstage, automountServiceAccountToken: false — the portal never talks to the Kubernetes API |
| Argo CD | deploy/argocd/platform/backstage-app.yaml, sync-wave 1 |
Zero-egress posture¶
Runtime: zero external egress by construction, the same self-contained-asset
posture this documentation site itself follows (no Google Fonts — Tend
design tokens / system font stack instead — no CDN scripts, no telemetry or
analytics), with every portal asset bundled into the image. A dedicated
Cilium lockdown was required beyond the general tend-system posture:
verification (not assumption) found the existing platform-services floor
didn't cover the portal, so a specific policy closes it — probe- and
devtools-confirmed to drop both external HTTP and external DNS resolution
from the portal pod while the estate API remains reachable — the generated
per-namespace perimeter is ADR-0007.
Revoked-scopes policy baseline¶
Platform-wide data-scope revocation (spec FR-021–FR-024) is delivered as a
GitOps policy change, not an imperative API — there is no revocation
endpoint anywhere in the platform. The merge of a PR to
deploy/policies/revoked-scopes-configmap.yaml is the revocation
action; git history on that file is the complete, tamper-evident audit
trail (Constitution VI).
apiVersion: v1
kind: ConfigMap
metadata:
name: revoked-scopes
namespace: tend-system
data:
revoked.yaml: |
revoked:
- name: <scope-name> # matches DataScope.Name platform-wide,
# not scoped to one app's declaration
revokedBy: <identity> # informational only — the PR author +
# merge is the authoritative record
at: <RFC3339>
reason: <free text>
The baseline ships empty (revoked: []) — revocation is always an explicit,
reviewed act, never a default-deny surprise. It is delivered by the existing
tend-policies Argo CD Application alongside the Kyverno and Cilium policy
manifests.
Propagation¶
Merge → Argo sync of the policies Application → each consumer acts within one of its own reconcile/refresh cycles of the sync landing (spec SC-007). No app is redeployed or restarted at any point.
Consumer obligations¶
| Consumer | Obligation |
|---|---|
| Data gateway | Deny any request whose evaluated scope name appears on the revoked list — regardless of what the calling app declares — logging decision=deny, reason="scope revoked" in the existing per-app access log (no schema change). Refreshes the list at least every 60 seconds (revokedscopes.DefaultTTL); a missing or unreadable ConfigMap yields an empty revoked list, never a deny-all — revocation is explicit, absence of the policy object is not. |
| Kyverno | A validate rule rejects any App CREATE that declares a revoked scope, and any UPDATE that newly introduces one, naming the scope in the rejection message (FR-024). An app that already declared the scope before it was revoked stays updatable — deliberately, so lifecycle actions on an affected app keep working; its runtime denial is the gateway's. Ships with a violating-manifest test under deploy/policies/kyverno/tests/app-scopes-not-revoked/ — the make policy-check gate. |
| Operator (LifecycleReconciler) | Projects the intersection of the revoked list and each app's spec.dataScopes onto status.lifecycle.revokedScopes; sets condition ScopeRevoked; emits ScopeRevokedAffected once per (app, scope) targeting the owner. Never freezes or suspends for revocation — enforcement is the gateway's alone, not the operator's. Removing the entry (un-revoking) clears the projection and the condition. |
| Estate API / views | Surface revokedScopes per app on GET /estate, and the ?revoked=true filter, so the estate view marks affected apps (FR-023). |
Invariants¶
- Revoking a scope no app declares is a recorded no-op — git history exists, nothing else changes.
- A frozen app's revoked scope still projects, and the denial still holds after the app unfreezes.
- The revoked list constrains gateway decisions and admission only — it
never mutates any App's
spec.dataScopes; the owner's declared intent stays untouched even while denied.
The merge-is-the-action doctrine¶
Revocation is the clearest instance of a pattern this feature makes explicit across the estate surface: some platform actions are imperative (an authenticated call that the operator executes and records — suspend, resume, retire, restore), and some are declarative GitOps changes where the merge itself is the action and git history is the audit trail (promotion approval, policy-baseline edits, and now scope revocation). Both produce a durable, queryable record; neither is a workflow engine. Which shape an action takes follows Constitution VI: anything that changes the platform's shared policy baseline is GitOps, and anything that changes one app's own posture is an authenticated, evidence-recorded action.