Skip to content

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 /estate on 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 Component per app, metadata.name = app name, spec.owner = the app's owner, spec.lifecycle = the estate environment value (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 /estate entirely 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.