From a1ab8ff807f0bbfa0c6c4900b8df3a431e987abb Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 28 Oct 2026 09:50:54 +0100 Subject: [PATCH] KEY-WP-0045: value-safe identity-facts export for NK-WP-0054-T04 keycape identity-facts --config PATH and make identity-facts print client registry, directory bind and admin-surface facts without credential values or secret reference names. Fixture and committed-config tests. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 308701@bnt-lap001 Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56 --- Makefile | 7 +- WORK-RECORDS.md | 4 + src/cmd/keycape/identityfacts.go | 220 ++++++++++++++++++ src/cmd/keycape/identityfacts_test.go | 89 +++++++ src/cmd/keycape/main.go | 4 + src/cmd/keycape/testdata/identity-facts.yaml | 39 ++++ ...Y-WP-0045-service-identity-facts-export.md | 71 ++++++ 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 src/cmd/keycape/identityfacts.go create mode 100644 src/cmd/keycape/identityfacts_test.go create mode 100644 src/cmd/keycape/testdata/identity-facts.yaml create mode 100644 workplans/KEY-WP-0045-service-identity-facts-export.md diff --git a/Makefile b/Makefile index 12e12e7..477a4a0 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ IMAGE_REPO ?= coulomb/key-cape IMAGE_TAG ?= latest IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG) -.PHONY: dev seed build test lint contract-test image push image-tag tokenstatus-export tokenstatus-check +.PHONY: dev seed build test lint contract-test image push image-tag tokenstatus-export tokenstatus-check identity-facts PLAYBOOK_VALIDATOR ?= ../net-kingdom/tools/playbook-capability-contract/playbook_contract_validator.py @@ -25,6 +25,11 @@ tokenstatus-export: tokenstatus-check: python3 tools/export_tokenstatus_client.py --check +# KEY-WP-0045: value-safe identity facts for NetKingdom's service-identity check. +IDENTITY_CONFIG ?= config/service-clients.example.yaml +identity-facts: + @cd src && go run ./cmd/keycape identity-facts --config $(abspath $(IDENTITY_CONFIG)) + lint: cd src && go vet ./... diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 35d9e54..485a371 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -55,6 +55,7 @@ | workplan | KEY-WP-0042 | proposed | — | workplans/KEY-WP-0042-online-access-token-status.md | | workplan | KEY-WP-0043 | proposed | — | workplans/KEY-WP-0043-per-run-agent-identity.md | | workplan | KEY-WP-0044 | proposed | — | workplans/KEY-WP-0044-audit-correlation-and-intake.md | +| workplan | KEY-WP-0045 | active | — | workplans/KEY-WP-0045-service-identity-facts-export.md | | task | ADHOC-2026-09-05-T01 | done | — | workplans/ADHOC-2026-09-05.md | | task | ADHOC-2026-09-07-T01 | done | — | workplans/ADHOC-2026-09-07.md | | task | ADHOC-2026-09-27-T01 | done | — | workplans/ADHOC-2026-09-27.md | @@ -235,4 +236,7 @@ | task | KEY-WP-0044-T01 | todo | — | workplans/KEY-WP-0044-audit-correlation-and-intake.md | | task | KEY-WP-0044-T02 | todo | — | workplans/KEY-WP-0044-audit-correlation-and-intake.md | | task | KEY-WP-0044-T03 | wait | — | workplans/KEY-WP-0044-audit-correlation-and-intake.md | +| task | KEY-WP-0045-T01 | done | — | workplans/KEY-WP-0045-service-identity-facts-export.md | +| task | KEY-WP-0045-T02 | wait | — | workplans/KEY-WP-0045-service-identity-facts-export.md | +| task | KEY-WP-0045-T03 | todo | — | workplans/KEY-WP-0045-service-identity-facts-export.md | | intake | KEY-IN-0001 | closed | — | intakes/intakes.md | diff --git a/src/cmd/keycape/identityfacts.go b/src/cmd/keycape/identityfacts.go new file mode 100644 index 0000000..8e86bd0 --- /dev/null +++ b/src/cmd/keycape/identityfacts.go @@ -0,0 +1,220 @@ +package main + +import ( + "encoding/json" + "flag" + "io" + "sort" + "strings" + + "keycape/internal/config" +) + +// identity-facts (KEY-WP-0045, NK-WP-0054-T04) exports the identity facts a +// KeyCape config establishes, for NetKingdom's service-identity conformance +// check. It is value-safe by construction: it copies only allow-listed +// registration fields and reports every credential-bearing field (client +// secretRef, LLDAP bindPW, privacyIDEA adminToken, Authelia clientSecret, +// privateKeyPem) as a kind or a boolean, never its content. Like check-config it +// reads no secret, key file or upstream, so it can run over a committed config +// or, run by the founder inside the pod, over the deployed one. + +const identityFactsSchema = "keycape-identity-facts/v0.1" + +type identityFacts struct { + Schema string `json:"schema"` + Issuer string `json:"issuer"` + Environment string `json:"environment"` + TokenLifetime string `json:"token_lifetime"` + Clients []clientFacts `json:"clients"` + DirectoryBind directoryBindFacts `json:"directory_bind"` + FactorService factorFacts `json:"factor_service"` + UpstreamOIDC upstreamFacts `json:"upstream_oidc"` + AdminSurfaces []adminSurface `json:"admin_surfaces"` + Findings []string `json:"findings"` +} + +type clientFacts struct { + ClientID string `json:"client_id"` + PrincipalType string `json:"principal_type"` + ClientType string `json:"client_type"` + GrantTypes []string `json:"grant_types"` + Audience string `json:"audience"` + AudienceSource string `json:"audience_source"` + Scopes []string `json:"scopes"` + ServiceSubject string `json:"service_subject,omitempty"` + Tenant string `json:"tenant,omitempty"` + Roles []string `json:"roles"` + TokenLifetime string `json:"token_lifetime"` + MFARequired *bool `json:"mfa_required,omitempty"` + Credential string `json:"credential"` +} + +type directoryBindFacts struct { + URL string `json:"url"` + BindDN string `json:"bind_dn"` + BaseDN string `json:"base_dn"` + PasswordInConfig bool `json:"password_in_config"` + BindIsDirectoryAdmin bool `json:"bind_is_directory_admin"` +} + +type factorFacts struct { + BaseURL string `json:"base_url"` + Realm string `json:"realm"` + AdminCredential string `json:"admin_credential"` + RequireForAll bool `json:"require_for_all"` +} + +type upstreamFacts struct { + BaseURL string `json:"base_url"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +type adminSurface struct { + Path string `json:"path"` + Enabled string `json:"enabled"` + Authority string `json:"authority"` +} + +// principalType maps a registration to the SecurityCanon principal type: a +// client that only uses client_credentials is a SERVICE; anything that signs a +// directory user in is presented on behalf of a HUMAN. +func principalType(grants []string) string { + for _, g := range grants { + if g != "client_credentials" { + return "HUMAN" + } + } + if len(grants) == 0 { + return "unknown" + } + return "SERVICE" +} + +func credentialKind(secretRef string) string { + switch { + case secretRef == "": + return "none" + case strings.HasPrefix(secretRef, "env:"): + return "env-ref" + default: + return "other-ref" + } +} + +func presence(inline string, file string) string { + switch { + case inline != "": + return "inline" + case file != "": + return "file" + default: + return "none" + } +} + +func orEmpty(s []string) []string { + if s == nil { + return []string{} + } + out := append([]string(nil), s...) + return out +} + +func buildIdentityFacts(cfg *config.Config) identityFacts { + facts := identityFacts{ + Schema: identityFactsSchema, + Issuer: cfg.Issuer, + Environment: cfg.Environment, + TokenLifetime: cfg.TokenLifetime, + Clients: []clientFacts{}, + Findings: []string{}, + } + for _, c := range cfg.Clients { + cf := clientFacts{ + ClientID: c.ClientID, + PrincipalType: principalType(c.GrantTypes), + ClientType: c.ClientType, + GrantTypes: orEmpty(c.GrantTypes), + Audience: c.Audience, + AudienceSource: "registration", + Scopes: orEmpty(c.AllowedScopes), + ServiceSubject: c.ServiceSubject, + Tenant: c.Tenant, + Roles: orEmpty(c.Roles), + TokenLifetime: c.TokenLifetime, + MFARequired: c.MFARequired, + Credential: credentialKind(c.SecretRef), + } + if cf.Audience == "" { + // token.go accessAudience: the access token falls back to the client id. + cf.Audience, cf.AudienceSource = c.ClientID, "client-id-default" + } + if cf.TokenLifetime == "" { + cf.TokenLifetime = cfg.TokenLifetime + } + facts.Clients = append(facts.Clients, cf) + } + sort.Slice(facts.Clients, func(i, j int) bool { return facts.Clients[i].ClientID < facts.Clients[j].ClientID }) + + bind := strings.ToLower(strings.TrimSpace(cfg.LLDAP.BindDN)) + facts.DirectoryBind = directoryBindFacts{ + URL: cfg.LLDAP.URL, + BindDN: cfg.LLDAP.BindDN, + BaseDN: cfg.LLDAP.BaseDN, + PasswordInConfig: cfg.LLDAP.BindPW != "", + BindIsDirectoryAdmin: strings.HasPrefix(bind, "uid=admin,") || strings.HasPrefix(bind, "cn=admin,"), + } + if facts.DirectoryBind.BindIsDirectoryAdmin { + facts.Findings = append(facts.Findings, "directory bind uses the directory administrator account, not a read-only bind identity") + } + facts.FactorService = factorFacts{ + BaseURL: cfg.PrivacyIDEA.BaseURL, + Realm: cfg.PrivacyIDEA.Realm, + AdminCredential: presence(cfg.PrivacyIDEA.AdminToken, cfg.PrivacyIDEA.AdminTokenFile), + RequireForAll: cfg.PrivacyIDEA.RequireForAll, + } + if facts.FactorService.AdminCredential == "inline" { + facts.Findings = append(facts.Findings, "privacyIDEA admin token is inline in the config rather than a mounted file") + } + facts.UpstreamOIDC = upstreamFacts{ + BaseURL: cfg.Authelia.BaseURL, + ClientID: cfg.Authelia.ClientID, + ClientSecret: presence(cfg.Authelia.ClientSecret, ""), + } + + tokenStatus := "no" + if cfg.TokenStatus.Enabled { + tokenStatus = "yes" + } + // Routes are fixed in cmd/keycape/main.go; these are the only ones that + // change or reveal state beyond the caller's own sign-in. + facts.AdminSurfaces = []adminSurface{ + {Path: "/platform/authentication-policy", Enabled: "when KEYCAPE_POLICY_PATH is set (deployment env, not config)", + Authority: "KeyCape token with azp user-engine-portal, role platform-operator, assurance aal2 with mfa within 300s"}, + {Path: "/introspect", Enabled: tokenStatus, Authority: "confidential client authentication; only tokens issued to the caller"}, + {Path: "/revoke", Enabled: tokenStatus, Authority: "confidential client authentication; only tokens issued to the caller"}, + } + return facts +} + +func runIdentityFacts(args []string, out io.Writer) int { + fs := flag.NewFlagSet("identity-facts", flag.ContinueOnError) + fs.SetOutput(io.Discard) + cfgPath := fs.String("config", "", "path to YAML config file") + if err := fs.Parse(args); err != nil || *cfgPath == "" { + _, _ = io.WriteString(out, "{\"error\":\"usage: keycape identity-facts --config PATH\"}\n") + return 1 + } + cfg, err := config.Load(*cfgPath) + if err != nil { + // The parse error can quote config content; say only that it failed. + _, _ = io.WriteString(out, "{\"error\":\"config does not load\"}\n") + return 1 + } + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + _ = enc.Encode(buildIdentityFacts(cfg)) + return 0 +} diff --git a/src/cmd/keycape/identityfacts_test.go b/src/cmd/keycape/identityfacts_test.go new file mode 100644 index 0000000..ea4a4f7 --- /dev/null +++ b/src/cmd/keycape/identityfacts_test.go @@ -0,0 +1,89 @@ +package main + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" +) + +func runFacts(t *testing.T, path string) (identityFacts, string) { + t.Helper() + var out bytes.Buffer + if code := runIdentityFacts([]string{"--config", path}, &out); code != 0 { + t.Fatalf("exit %d: %s", code, out.String()) + } + var facts identityFacts + if err := json.Unmarshal(out.Bytes(), &facts); err != nil { + t.Fatalf("output is not JSON: %v", err) + } + return facts, out.String() +} + +func TestIdentityFactsFixtureIsValueSafe(t *testing.T) { + facts, raw := runFacts(t, "testdata/identity-facts.yaml") + if strings.Contains(raw, "CANARY") { + t.Fatalf("a credential value or reference name reached the export:\n%s", raw) + } + if facts.Schema != identityFactsSchema || len(facts.Clients) != 2 { + t.Fatalf("unexpected facts: %+v", facts) + } + browser, svc := facts.Clients[0], facts.Clients[1] + if svc.ClientID != "svc-reader" || svc.PrincipalType != "SERVICE" || svc.Credential != "env-ref" || + svc.Audience != "svc-reader" || svc.AudienceSource != "client-id-default" || + !reflect.DeepEqual(svc.Scopes, []string{"thing:read"}) || svc.TokenLifetime != "5m" { + t.Fatalf("service client: %+v", svc) + } + if browser.ClientID != "browser-app" || browser.PrincipalType != "HUMAN" || browser.Credential != "none" || + browser.Audience != "resource-api" || browser.AudienceSource != "registration" || + browser.TokenLifetime != "15m" || browser.MFARequired == nil || !*browser.MFARequired { + t.Fatalf("browser client: %+v", browser) + } + d := facts.DirectoryBind + if d.BindDN != "uid=admin,ou=people,dc=example,dc=test" || !d.PasswordInConfig || !d.BindIsDirectoryAdmin { + t.Fatalf("directory bind: %+v", d) + } + if facts.FactorService.AdminCredential != "inline" || facts.UpstreamOIDC.ClientSecret != "inline" { + t.Fatalf("credential kinds: %+v %+v", facts.FactorService, facts.UpstreamOIDC) + } + if len(facts.Findings) != 2 || len(facts.AdminSurfaces) != 3 || facts.AdminSurfaces[1].Enabled != "yes" { + t.Fatalf("findings/admin surfaces: %+v %+v", facts.Findings, facts.AdminSurfaces) + } +} + +// The committed registration fragments establish the token scopes of the +// clients NetKingdom's keycape declaration lists as unknown (NK-WP-0054-T04). +func TestIdentityFactsCommittedServiceClients(t *testing.T) { + facts, raw := runFacts(t, "../../../config/service-clients.example.yaml") + if strings.Contains(raw, "_CLIENT_SECRET") || strings.Contains(raw, "env:") { + t.Fatalf("secret reference names reached the export") + } + want := map[string][]string{ + "secrets-engine-approval": {"approval:read", "approval:consume"}, + "secrets-engine-requester": {"approval:create"}, + "approval-engine-operator": {"approval:create", "approval:read", "approval:approve", "approval:revoke", "approval:supersede", "approval:observe", "approval:emit"}, + "informed-decision-sitting-requester": {"approval:create"}, + "secrets-engine-openbao": {"openbao:login"}, + } + seen := 0 + for _, c := range facts.Clients { + if scopes, ok := want[c.ClientID]; ok { + seen++ + if !reflect.DeepEqual(c.Scopes, scopes) || c.PrincipalType != "SERVICE" { + t.Fatalf("%s: %+v", c.ClientID, c) + } + } + } + if seen != len(want) { + t.Fatalf("found %d of %d clients", seen, len(want)) + } +} + +func TestIdentityFactsRefusesUnloadableConfigQuietly(t *testing.T) { + var out bytes.Buffer + if code := runIdentityFacts([]string{"--config", "testdata/missing.yaml"}, &out); code != 1 || + !strings.Contains(out.String(), "config does not load") { + t.Fatalf("exit %d: %s", code, out.String()) + } +} diff --git a/src/cmd/keycape/main.go b/src/cmd/keycape/main.go index 818b239..220464b 100644 --- a/src/cmd/keycape/main.go +++ b/src/cmd/keycape/main.go @@ -41,6 +41,10 @@ func main() { os.Exit(runCheckConfig(os.Args[2:], os.Stdout)) } + if len(os.Args) > 1 && os.Args[1] == "identity-facts" { + os.Exit(runIdentityFacts(os.Args[2:], os.Stdout)) + } + if len(os.Args) > 1 && os.Args[1] == "probe-upstream-issuer" { if err := authelia.RunIssuerProbe(context.Background(), os.Args[2:], os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) diff --git a/src/cmd/keycape/testdata/identity-facts.yaml b/src/cmd/keycape/testdata/identity-facts.yaml new file mode 100644 index 0000000..8a8e844 --- /dev/null +++ b/src/cmd/keycape/testdata/identity-facts.yaml @@ -0,0 +1,39 @@ +# Synthetic fixture for keycape identity-facts. Every credential-shaped field holds +# a CANARY marker so the test can prove no such value reaches the export. +issuer: "https://issuer.example.test" +environment: "test" +tokenLifetime: "15m" +privateKeyPem: "/etc/keycape/key.pem" +lldap: + url: "ldap://lldap.example.test:3890" + bindDN: "uid=admin,ou=people,dc=example,dc=test" + bindPW: "CANARY-bind-password" + baseDN: "dc=example,dc=test" +authelia: + baseURL: "https://auth.example.test" + clientId: "keycape" + clientSecret: "CANARY-upstream-secret" +privacyidea: + baseURL: "http://pi.example.test:8080" + realm: "example" + adminToken: "CANARY-admin-token" + requireForAll: true +tokenStatus: + enabled: true +clients: + - clientId: "svc-reader" + allowedScopes: ["thing:read"] + grantTypes: ["client_credentials"] + clientType: "confidential" + secretRef: "env:CANARY_SVC_READER_SECRET" + serviceSubject: "service:reader" + tenant: "tenant:platform" + roles: ["reader"] + tokenLifetime: "5m" + - clientId: "browser-app" + audience: "resource-api" + redirectUris: ["https://app.example.test/cb"] + allowedScopes: ["openid", "thing:approve"] + grantTypes: ["authorization_code"] + clientType: "public" + mfaRequired: true diff --git a/workplans/KEY-WP-0045-service-identity-facts-export.md b/workplans/KEY-WP-0045-service-identity-facts-export.md new file mode 100644 index 0000000..66f7551 --- /dev/null +++ b/workplans/KEY-WP-0045-service-identity-facts-export.md @@ -0,0 +1,71 @@ +--- +id: KEY-WP-0045 +type: workplan +title: "Value-safe identity facts export for the service-identity conformance check" +domain: infotech +repo: key-cape +status: active +owner: claude-code +topic_slug: custodian +created: "2026-10-28" +updated: "2026-10-28" +flavor: extension +depends_on: [] +--- + +Accepted from net-kingdom NK-WP-0054-T04 (State Hub message 74fdd863). NetKingdom's +`tools/service-identity/validate.py` compares declared identity facts with observed +ones under `canon/standards/service-identity-privilege_v0.1.md`; KeyCape supplies the +observed side as non-secret metadata. Nothing here reads a Secret, changes a live +deployment or registers anything. + +## Export identity facts from a KeyCape config + +```task +id: KEY-WP-0045-T01 +status: done +priority: high +``` + +`keycape identity-facts --config PATH` (`src/cmd/keycape/identityfacts.go`) and +`make identity-facts [IDENTITY_CONFIG=path]` print schema +`keycape-identity-facts/v0.1`: per client the id, principal type, client type, grant +types, access-token audience (and whether it is registered or the client-id +default), scopes, service subject, tenant, roles, lifetime, MFA requirement and the +credential *kind*; the LLDAP bind DN, base DN and whether the bind is the directory +administrator; the privacyIDEA and Authelia credential kinds; and the fixed admin +surfaces. Credential fields and secret reference names are never copied. Tests: +`src/cmd/keycape/identityfacts_test.go` over a canary fixture and the committed +`config/service-clients.example.yaml`. + +## Live view of the deployed registry + +```task +id: KEY-WP-0045-T02 +status: wait +priority: medium +``` + +The deployed config is the `config.yaml` key of Secret `sso/keycape-config`, and the +running image is distroless and predates this subcommand, so an agent cannot produce +the live view. Founder-run, streaming the config through the exporter without +writing it to disk: + + kubectl -n sso get secret keycape-config -o jsonpath='{.data.config\.yaml}' \ + | base64 -d | (cd ~/key-cape/src && go run ./cmd/keycape identity-facts --config /dev/stdin) \ + > ~/keycape-identity-facts.live.json + +The output is value-safe and can be handed to NetKingdom. Waiting on the founder. + +## Directory bind identity + +```task +id: KEY-WP-0045-T03 +status: todo +priority: medium +``` + +The NetKingdom reference config binds LLDAP as `uid=admin,ou=people,...`, the +directory administrator; KeyCape only needs read access (`internal/adapters/lldap`). +A read-only bind account is a cross-repo change (LLDAP owner, custody, rollout) and +needs a NetKingdom/founder decision before any work.