KEY-WP-0045: value-safe identity-facts export for NK-WP-0054-T04
Some checks failed
Authentication acceptance / acceptance (push) Failing after 1m12s
Authentication acceptance / provider-contract (push) Successful in 23s
Build and Publish Container Image / build-and-push (push) Successful in 40s

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 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
tegwick 2026-10-28 09:50:54 +01:00
parent 1af86580e3
commit a1ab8ff807
7 changed files with 433 additions and 1 deletions

View file

@ -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 ./...

View file

@ -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 |

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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)

View file

@ -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

View file

@ -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.