Add bounded resource audiences and enforce browser scope grants
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 36s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06e87-e039-7ed2-b85c-20ad37f8a21b
This commit is contained in:
tegwick 2026-09-05 00:41:17 +02:00
parent b8dda4115a
commit 403904b901
22 changed files with 449 additions and 35 deletions

View file

@ -2,18 +2,16 @@
# Custodian Brief — key-cape
**Domain:** infotech
**Last synced:** 2026-08-25 17:53 UTC
**Last synced:** 2026-09-04 22:33 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Active Workstreams
*(none — repo may need first-session setup)*
### Approval-engine resource audience and client registrations
Progress: 1/2 done | workplan_id: `6e815d88-b0e3-5ce0-be5d-13ab15917f7f`
## Inbox Hygiene
**Stale unread:** 1 message(s) older than 3 day(s) — triage at session start.
**Missing thread_id:** 1 unread message(s) lack supersession chains.
- ! state-hub: [BREAKING] Task status canon: wait/todo/progress/done/cancel `8dbd52c2`
**Open tasks:**
- ! Provision and prove live registrations `607897c5`
---
## MCP Orientation (when available)

3
.gitignore vendored
View file

@ -174,3 +174,6 @@ cython_debug/
# PyPI configuration file
.pypirc
# Generated workstation repository index
.repo-manager/

View file

@ -1,19 +1,15 @@
# INTENT
> **NetKingdom layering review — 2026-08-28.** This repository's role was reviewed
> against the NetKingdom IT-security layer model: **Taxonomy → Tooling → Engines →
> Staff**, layered by determinism and by the kind of artifact each layer produces.
> Findings and the argument behind them:
> `gate-house/history/2026-08-28-security-layer-model-and-gate-house-recut.md`.
> The model is `net-kingdom/canon/standards/security-layer-model_v0.1.md` (proposed),
> ratified by `gate-house/decisions/decisions.md` GH-DEC-2026-001.
>
> The layer rule that binds every repository: **Staff never touches tooling
> directly. It acts only through engine APIs.**
>
> **This repository is Tooling — deterministic infrastructure and state.** State the layer: key-cape packages the third-party identity tooling (authelia, lldap, privacy-idea) behind a versioned IAM profile. It owns authentication and identity claims only. Because "auth" is ambiguous across the estate, this INTENT should say explicitly that key-cape is **not** an authorization decision point — that is access-engine's, and it stays the only one.
>
> *This note records what should change. The body below is not yet adapted.*
KeyCape declares its layer as **Tooling** under the NetKingdom security layer
model v0.4. It provides deterministic authentication and identity claims through
the versioned IAM profile, packaging Authelia, LLDAP, and privacyIDEA.
Authorization decisions belong to access-engine; KeyCape is not an authorization
decision point. Issuing a role or scope claim does not decide access to a resource.
These backend components are implementation dependencies of KeyCape's tooling
boundary. Separate catalog entries are unnecessary until they expose independently
owned estate capabilities. Staff integration follows engine APIs as required by
the layer model; this declaration does not certify every existing caller path.
## Purpose

View file

@ -8,8 +8,9 @@
| Kind | ID | Status | Lane | Source |
| --- | --- | --- | --- | --- |
| workplan | KEY-WP-0001 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| workplan | KEY-WP-0002 | done | — | workplans/KEY-WP-0002-container-image-gitea.md |
| workplan | ADHOC-2026-09-05 | finished | — | workplans/ADHOC-2026-09-05.md |
| workplan | KEY-WP-0001 | finished | — | workplans/KEY-WP-0001-keycape-implementation.md |
| workplan | KEY-WP-0002 | finished | — | workplans/KEY-WP-0002-container-image-gitea.md |
| workplan | KEY-WP-0003 | finished | — | workplans/KEY-WP-0003-bootstrap-console-oidc-mfa-login.md |
| workplan | KEY-WP-0004 | finished | — | workplans/KEY-WP-0004-binky-hedgehog-tenant-onboarding.md |
| workplan | KEY-WP-0005 | finished | — | workplans/KEY-WP-0005-iam-profile-core-claims.md |
@ -19,6 +20,10 @@
| workplan | KEY-WP-0009 | finished | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
| workplan | KEY-WP-0010 | finished | — | workplans/KEY-WP-0010-openbao-operator-loopback-callback.md |
| workplan | KEY-WP-0011 | finished | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| workplan | KEY-WP-0012 | finished | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| workplan | KEY-WP-0013 | blocked | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| workplan | KEY-WP-0014 | proposed | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md |
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
| task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
@ -83,4 +88,11 @@
| task | KEY-WP-0011-T01 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| task | KEY-WP-0011-T02 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| task | KEY-WP-0011-T03 | done | — | workplans/KEY-WP-0011-live-secret-exposure-recovery.md |
| intake | KEY-IN-0001 | open | — | intakes/intakes.md |
| task | KEY-WP-0012-T01 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| task | KEY-WP-0012-T02 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| task | KEY-WP-0012-T03 | done | — | workplans/KEY-WP-0012-userinfo-canonical-subject-resolution.md |
| task | KEY-WP-0013-T01 | done | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0013-T02 | wait | — | workplans/KEY-WP-0013-approval-engine-resource-audience.md |
| task | KEY-WP-0014-T01 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| task | KEY-WP-0014-T02 | todo | — | workplans/KEY-WP-0014-native-credential-lane-handoff.md |
| intake | KEY-IN-0001 | closed | — | intakes/intakes.md |

View file

@ -1,5 +1,4 @@
# Non-secret static registration fragments for KeyCape's bounded OpenBao JWT
# consumers. Merge these entries into the deployment-owned KeyCape config.
# Non-secret static registration fragments for KeyCape's bounded JWT consumers. Merge these entries into the deployment-owned KeyCape config.
# The named environment variables must be materialized by an approved
# out-of-repository custody path; this file never contains their values.
clients:
@ -24,3 +23,27 @@ clients:
tenant: "tenant:coulomb"
roles: ["secrets-engine"]
tokenLifetime: "15m"
- clientId: "secrets-engine-approval"
displayName: "secrets-engine approval consume client"
audience: "approval-engine"
allowedScopes: ["approval:read", "approval:consume"]
grantTypes: ["client_credentials"]
clientType: "confidential"
secretRef: "env:KEYCAPE_SECRETS_ENGINE_APPROVAL_CLIENT_SECRET"
serviceSubject: "service:secrets-engine"
tenant: "tenant:coulomb"
roles: ["secrets-engine"]
tokenLifetime: "15m"
- clientId: "approval-engine-operator"
displayName: "approval-engine lifecycle operator"
audience: "approval-engine"
allowedScopes: ["approval:create", "approval:read", "approval:approve", "approval:revoke", "approval:supersede", "approval:observe", "approval:emit"]
grantTypes: ["client_credentials"]
clientType: "confidential"
secretRef: "env:KEYCAPE_APPROVAL_ENGINE_OPERATOR_CLIENT_SECRET"
serviceSubject: "service:approval-engine-operator"
tenant: "tenant:coulomb"
roles: ["approval-operator"]
tokenLifetime: "15m"

View file

@ -0,0 +1,31 @@
# Approval-engine token contract
Static client registrations may set `audience: approval-engine`. This selects
only the access-token audience; OIDC ID tokens retain `aud=clientId`. Omitting
`audience` preserves the existing client-ID access audience, including OpenBao
consumers. Request `audience` and `resource` parameters cannot override it.
Access tokens contain the granted `scope` string for both supported grant types.
`config/service-clients.example.yaml` provides the two requested confidential
clients: secrets-engine-approval gets read/consume, and approval-engine-operator
gets lifecycle/observation scopes without consume. Service tokens contain
`principal_type=service`, tenant, roles, scope, assurance, issuer, subject,
audience, issue time and expiry; the lifetime is 15 minutes. The issuer signs
with RS256 and publishes its public key through `/jwks`.
Human approvers need a separate authorization-code/PKCE registration with an
exact deployment-owned callback, `audience: approval-engine`,
`allowedScopes: [openid, approval:approve]`, and `mfaRequired: true`. Do not add
consume or other approval grants to that client. No callback is invented here.
The ID token is for the login client; present the access token to approval-engine.
These fragments are not live registrations. Deployment requires custody-managed
values for the named environment references, the exact human callback, and a
rollout of this version. Never log the token or secret. Verify the resulting
access token against the deployed issuer's `/jwks`, checking issuer, audience,
expiry, subject, principal type, tenant, roles, scope and assurance. Verify that
operator consume and human consume requests are rejected. Local tests verify
signatures against the JWKS handler; they do not constitute live issuance proof.
KeyCape owns issuance and client grants/disablement. OpenBao and the deployment
operator own credential custody; approval-engine enforces its resource policy.

View file

@ -7,7 +7,7 @@ id: KEY-IN-0001
kind: intake
title: 'Declaration requested: state this repository''s layer in INTENT.md (security
layer model §11)'
status: open
status: closed
origin: cross-repo
origin_ref: net-kingdom security-layer-model_v0.4 §11
priority: low
@ -34,5 +34,7 @@ description: 'A conformance sweep on 2026-08-28 found this repository has no lay
actually does, that is more useful to us than a label added to close a checkbox.
Standard: net-kingdom/canon/standards/security-layer-model_v0.4.md.'
created: '2026-08-28T21:01:53.604723Z'
updated: '2026-08-28T21:01:53.604723Z'
updated: '2026-09-05'
resolution: 'Declared Tooling ownership and authorization boundary in INTENT.md; backend components remain implementation dependencies.'
state_hub_intake_id: "01a06e8d-5b4b-7202-96e3-c7304640c3a6"
```

View file

@ -0,0 +1,38 @@
package main
import (
"keycape/internal/config"
"testing"
"time"
)
func TestServiceRegistrationAudienceAndScopeIsolation(t *testing.T) {
cfg, err := config.Load("../../../config/service-clients.example.yaml")
if err != nil {
t.Fatal(err)
}
for _, c := range cfg.Clients {
t.Setenv(c.SecretRef[4:], "test-only-secret")
}
registry, err := buildClientRegistry(cfg.Clients)
if err != nil {
t.Fatal(err)
}
for _, id := range []string{"secrets-engine-approval", "approval-engine-operator"} {
c := registry[id]
if c == nil || c.Audience != "approval-engine" || c.TokenLifetime != 15*time.Minute {
t.Fatalf("invalid registration for %s", id)
}
for _, scope := range c.AllowedScopes {
if id == "approval-engine-operator" && scope == "approval:consume" {
t.Fatal("operator may not consume")
}
if id == "secrets-engine-approval" && scope != "approval:read" && scope != "approval:consume" {
t.Fatal("excess PEP scope")
}
}
}
if registry["secrets-engine-openbao"].Audience != "" {
t.Fatal("OpenBao audience default changed")
}
}

View file

@ -290,6 +290,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
ClientType: c.ClientType,
SecretRef: c.SecretRef,
ClientSecret: clientSecret,
Audience: c.Audience,
ServiceSubject: c.ServiceSubject,
Tenant: c.Tenant,
Roles: c.Roles,

View file

@ -30,6 +30,7 @@ type Config struct {
// ClientConfig is a static OIDC client registration.
type ClientConfig struct {
ClientID string `yaml:"clientId"`
Audience string `yaml:"audience,omitempty"`
DisplayName string `yaml:"displayName"`
RedirectURIs []string `yaml:"redirectUris"`
AllowedScopes []string `yaml:"allowedScopes"`

View file

@ -385,8 +385,8 @@ func TestServiceClientExampleContracts(t *testing.T) {
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("service client examples must validate: %v", errs)
}
if len(cfg.Clients) != 2 {
t.Fatalf("service client examples: want 2, got %d", len(cfg.Clients))
if len(cfg.Clients) != 4 {
t.Fatalf("service client examples: want 4, got %d", len(cfg.Clients))
}
codingAgent := cfg.Clients[0]
@ -479,3 +479,20 @@ func containsErr(errs []string, substring string) bool {
}
return false
}
func TestValidateConfigAudience(t *testing.T) {
for _, audience := range []string{"", "approval-engine", "https://api.example.com"} {
cfg := validConfig("key.pem")
cfg.Clients[0].Audience = audience
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
t.Fatalf("valid audience %q: %v", audience, errs)
}
}
for _, audience := range []string{" ", "approval-engine other", "approval-engine\n"} {
cfg := validConfig("key.pem")
cfg.Clients[0].Audience = audience
if errs := config.ValidateConfig(cfg); len(errs) == 0 {
t.Fatalf("accepted audience %q", audience)
}
}
}

View file

@ -42,6 +42,9 @@ func ValidateConfig(cfg *Config) []string {
prefix = fmt.Sprintf("clients[%d]", i)
errs = append(errs, prefix+": clientId must not be empty")
}
if strings.TrimSpace(c.Audience) != c.Audience || strings.ContainsAny(c.Audience, " \t\r\n") {
errs = append(errs, prefix+": audience must be a single non-whitespace identifier")
}
hasAuthorizationCode := contains(c.GrantTypes, "authorization_code")
hasClientCredentials := contains(c.GrantTypes, "client_credentials")
if (hasAuthorizationCode || !hasClientCredentials) && len(c.RedirectURIs) == 0 {

View file

@ -50,6 +50,7 @@ type Client struct {
ClientType string `yaml:"clientType" json:"clientType"` // "confidential" | "public"
SecretRef string `yaml:"secretRef,omitempty" json:"secretRef,omitempty"`
ClientSecret string `yaml:"-" json:"-"`
Audience string `yaml:"audience,omitempty" json:"audience,omitempty"`
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`

View file

@ -0,0 +1,134 @@
package oidc_test
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"keycape/internal/domain"
"keycape/internal/server/oidc"
"keycape/internal/server/telemetry"
)
func verifyWithJWKS(t *testing.T, h *oidc.TokenHandler, token string) {
t.Helper()
keys := oidc.NewKeySet()
keys.AddKey("key-1", &h.SigningKey.PublicKey)
w := httptest.NewRecorder()
oidc.NewJWKSHandler(keys).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/jwks", nil))
var response struct {
Keys []oidc.JWK `json:"keys"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if len(response.Keys) != 1 {
t.Fatal("missing signing key")
}
key := response.Keys[0]
n, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
t.Fatal(err)
}
e, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatal("invalid JWT")
}
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatal(err)
}
digest := sha256.Sum256([]byte(parts[0] + "." + parts[1]))
pub := rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}
if err := rsa.VerifyPKCS1v15(&pub, crypto.SHA256, digest[:], sig); err != nil {
t.Fatal(err)
}
}
func TestServiceAudienceIsStaticAndDefaultsToClientID(t *testing.T) {
for _, audience := range []string{"", "approval-engine"} {
t.Run(audience, func(t *testing.T) {
h := serviceTokenHandler(t)
h.ClientConfig["rapp-qonto"].Audience = audience
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}, "audience": {"attacker"}, "resource": {"attacker"}})
req.SetBasicAuth("rapp-qonto", "test-service-secret")
w := httptest.NewRecorder()
h.ServeHTTP(w, req)
if w.Code != 200 {
t.Fatalf("status %d", w.Code)
}
token := decodeTokenResponse(t, w.Body.String())["access_token"].(string)
want := audience
if want == "" {
want = "rapp-qonto"
}
if parseJWTPayload(t, token)["aud"] != want {
t.Fatal("wrong audience")
}
verifyWithJWKS(t, h, token)
})
}
}
func TestHumanResourceAudiencePreservesIDTokenAudience(t *testing.T) {
sessions := oidc.NewSessionStore()
h, _ := newTokenHandler(t, sessions, &mockUserRepo{users: map[string]*domain.User{"alice": aliceUser()}})
h.ClientConfig["test-client"].Audience = "approval-engine"
h.ClientConfig["test-client"].AllowedScopes = []string{"openid", "approval:approve"}
verifier := "test-verifier"
code := seededSession(sessions, verifier)
sess, _ := sessions.Get(code)
sess.Scopes = []string{"openid", "approval:approve"}
w := httptest.NewRecorder()
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {verifier}}))
if w.Code != 200 {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
response := decodeTokenResponse(t, w.Body.String())
access := response["access_token"].(string)
id := response["id_token"].(string)
claims := parseJWTPayload(t, access)
if claims["aud"] != "approval-engine" || claims["scope"] != "openid approval:approve" || claims["principal_type"] != "human" {
t.Fatalf("wrong access claims: %v", claims)
}
if parseJWTPayload(t, id)["aud"] != "test-client" {
t.Fatal("ID token audience changed")
}
verifyWithJWKS(t, h, access)
verifyWithJWKS(t, h, id)
}
func TestHumanExcessScopeRejectedBeforeAuthentication(t *testing.T) {
h := newAuthorizeHandler(nil, nil, telemetry.NoopEmitter{})
params := validAuthorizeParams()
params.Set("scope", "openid approval:consume")
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/authorize?"+params.Encode(), nil))
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d", w.Code)
}
}
func TestTokenRejectsScopeRemovedAfterAuthorization(t *testing.T) {
sessions := oidc.NewSessionStore()
h, _ := newTokenHandler(t, sessions, &mockUserRepo{})
code := seededSession(sessions, "verifier")
h.ClientConfig["test-client"].AllowedScopes = []string{"openid"}
w := httptest.NewRecorder()
h.ServeHTTP(w, tokenRequest(url.Values{"grant_type": {"authorization_code"}, "client_id": {"test-client"}, "code": {code}, "code_verifier": {"verifier"}}))
if w.Code != http.StatusBadRequest {
t.Fatalf("status %d", w.Code)
}
}

View file

@ -190,6 +190,13 @@ func (h *AuthorizeHandler) serveAuthorize(w http.ResponseWriter, r *http.Request
return
}
for _, requestedScope := range strings.Fields(scope) {
if !containsString(client.AllowedScopes, requestedScope) {
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest)
return
}
}
// 5. Validate code_challenge is present.
if codeChallenge == "" {
profileerrors.InvalidProfileUsage(

View file

@ -94,7 +94,7 @@ func testClient() map[string]*domain.Client {
ClientID: "test-client",
DisplayName: "Test Client",
RedirectURIs: []string{"https://app.example.com/callback"},
AllowedScopes: []string{"openid", "profile", "email"},
AllowedScopes: []string{"openid", "profile", "email", "groups"},
ClientType: "public",
},
"netkingdom-bootstrap-console": {

View file

@ -93,6 +93,14 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Recheck grants in case the client registration changed after authorization.
for _, scope := range sess.Scopes {
if !containsString(h.ClientConfig[clientID].AllowedScopes, scope) {
profileerrors.InvalidProfileUsage("requested scope is not allowed", "scope").Write(w, http.StatusBadRequest)
return
}
}
// 4. Verify PKCE code_verifier.
if !verifyPKCE(codeVerifier, sess.PKCEChallenge) {
profileerrors.InvalidProfileUsage(
@ -169,12 +177,24 @@ func (h *TokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
// Access tokens target the statically registered resource server. ID tokens
// remain bound to the OIDC relying party.
if audience := h.ClientConfig[clientID].Audience; audience != "" {
claims["aud"] = audience
}
claims["scope"] = strings.Join(sess.Scopes, " ")
accessToken, err := buildJWT(claims, kid, h.SigningKey)
if err != nil {
http.Error(w, "failed to build JWT", http.StatusInternalServerError)
return
}
// 8. Delete used PKCE session (prevent replay).
h.Sessions.Delete(code)
// 9. Build response.
resp := tokenResponse{
AccessToken: jwtToken,
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: int(h.TokenLifetime.Seconds()),
IDToken: jwtToken,
@ -239,7 +259,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
claims := map[string]interface{}{
"iss": h.Issuer,
"sub": client.ServiceSubject,
"aud": clientID,
"aud": accessAudience(client),
"exp": now.Add(tokenLifetime).Unix(),
"iat": now.Unix(),
"tenant": client.Tenant,
@ -393,3 +413,11 @@ func buildJWT(claims map[string]interface{}, kid string, key *rsa.PrivateKey) (s
return strings.Join([]string{hdrB64, payloadB64, sigB64}, "."), nil
}
// accessAudience is configured by the issuer, never selected by request input.
func accessAudience(client *domain.Client) string {
if client.Audience != "" {
return client.Audience
}
return client.ClientID
}

View file

@ -0,0 +1,27 @@
---
id: ADHOC-2026-09-05
type: workplan
title: "Repository review and layer declaration"
domain: infotech
repo: key-cape
status: finished
owner: codex
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "6f5a12f0-5dac-5e02-bbd9-9ab2263264ba"
---
## Resolve declaration intake and normalize repository records
```task
id: ADHOC-2026-09-05-T01
status: done
priority: low
state_hub_task_id: "54c89abe-2539-550d-bc3a-649af8d4580a"
```
Reviewed KEY-IN-0001 against local security-layer-model_v0.4. Declared Tooling
and the authentication/authorization boundary in INTENT.md and closed the intake.
Normalized two legacy completed workplan frontmatter statuses to finished.
Ignored the generated .repo-manager workstation cache. Existing task statuses
already conform to the State Hub task lifecycle canon.

View file

@ -4,7 +4,7 @@ type: workplan
title: "KeyCape Implementation — Lightweight IAM Profile"
domain: infotech
repo: key-cape
status: done
status: finished
owner: Bernd
topic_slug: netkingdom
topic_id: a6c6e745-bf54-4465-9340-1534a2be493e

View file

@ -4,7 +4,7 @@ type: workplan
title: "KeyCape Container Image — Build & Publish to Gitea OCI Registry"
domain: infotech
repo: key-cape
status: done
status: finished
owner: netkingdom
topic_slug: netkingdom
created: "2026-03-22"

View file

@ -0,0 +1,47 @@
---
id: KEY-WP-0013
type: workplan
title: "Approval-engine resource audience and client registrations"
domain: infotech
repo: key-cape
status: blocked
owner: codex
topic_slug: approval-engine-resource-audience
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "6e815d88-b0e3-5ce0-be5d-13ab15917f7f"
---
Source: approval-engine inbox request 5583e896-52f2-45bd-895f-02f227b7e836,
reviewed against its local registration and caller-authentication contracts.
## Implement static resource audiences
```task
id: KEY-WP-0013-T01
status: done
priority: high
state_hub_task_id: "a92432a2-4e92-5b90-be2b-d82b784bf8f0"
```
Added optional static audience configuration for access tokens in both grants;
ID tokens retain the relying-party audience. Added human access-token scope.
Published bounded approval service fragments and the human registration contract.
Regression tests cover the default audience, request override resistance, JWKS
signature validation, ID-token separation and service registration scope isolation.
Browser requests and token exchanges now enforce the client scope allow-list,
including grants removed after authorization.
## Provision and prove live registrations
```task
id: KEY-WP-0013-T02
status: wait
priority: high
state_hub_task_id: "607897c5-bad9-55e5-86df-7802f592d6e8"
```
Needs deployment-owned custody for both new secret references and an exact human
callback registration. Deploy the implementation and registrations together,
then prove live JWKS verification and denied excess scopes without logging values.
Local signature proof is not live rollout evidence. See docs/approval-engine-auth-contract.md.

View file

@ -0,0 +1,45 @@
---
id: KEY-WP-0014
type: workplan
title: "Review native login and client credential lane handoffs"
domain: infotech
repo: key-cape
status: proposed
owner: codex
topic_slug: native-credential-lane-handoff
created: "2026-09-05"
updated: "2026-09-05"
state_hub_workstream_id: "0d003df3-f7d3-5063-8ca0-e1e33f7df74a"
---
Source: ops-warden inbox message 0dd9c7bd-0ecd-42d1-806f-7cc4ba9730ed.
The issuer supports client_secret_basic exchange, but has no native exchange or
rotation CLI. Existing machine issuance ownership does not complete this handoff.
## Design owner command and custody boundaries
```task
id: KEY-WP-0014-T01
status: todo
priority: medium
state_hub_task_id: "0c0a0b61-c19e-5631-9cda-8b2dc0f47d8f"
```
Review ops-warden's existing key-cape-oidc-login proxy and
rapp-qonto-keycape-client route contracts. Specify the native interactive login
and bounded exchange commands, token delivery, renewal and custody-mediated
rotation before implementation. Keep secret custody with OpenBao and avoid
retiring the proxy until replacement commands have equivalent verification.
## Verify handoff delivery evidence
```task
id: KEY-WP-0014-T02
status: todo
priority: low
state_hub_task_id: "d9a5de97-b7d5-5599-98c2-eaab32f51495"
```
Ops-warden reports KEY-WP-0009-T04's claimed reply did not arrive. Verify prior
receipts for all four named recipients before claiming successful notification.
No outbound coordination messages were sent during the 2026-09-05 repo review.