Declare live platform-admin role and policies; add attended admin check
Answers the-custodian 641673a4. An attended read-only check (receipt docs/evidence/2026-09-23-openbao-platform-admin-check.json) found: - live platform-admin policy = repo file + reins/* (ops-mason, 2026-07-27); repo now matches live (sha256 0ca5b821...). No live write. - role also attaches operator-custody (undeclared); declared here. - role declared as openbao/auth/netkingdom-platform-admin-role.json. - default policy is attached and grants revoke-self/lookup-self, so the suspected missing grant is not the cause of warden's unconfirmed revocation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 150322@bnt-lap001 Assistant-Session: 16a7b788-374e-4915-a1df-fc87ffd9a5e4
This commit is contained in:
parent
32ccb1acab
commit
3fbb610d1f
8 changed files with 525 additions and 0 deletions
97
docs/evidence/2026-09-23-openbao-platform-admin-check.json
Normal file
97
docs/evidence/2026-09-23-openbao-platform-admin-check.json
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
{
|
||||||
|
"applied": false,
|
||||||
|
"attached_policy_sha256": {
|
||||||
|
"default": "cf5e073df536011f977880d58be6a32acb04cfcd195364241f2d99f6601c2e3f",
|
||||||
|
"operator-custody": "84eeabeeb11c15e2f8940ed23d6ecc434a3299405fd70ea9c05d959f925970f7",
|
||||||
|
"platform-admin": "0ca5b821666bdf262bb0742a0031a4f0f9f3d315ef85e951d6b9ce20e7563394"
|
||||||
|
},
|
||||||
|
"changed": false,
|
||||||
|
"credential_values_emitted": false,
|
||||||
|
"observed_at": "2026-09-23T17:25:18.820429+00:00",
|
||||||
|
"policy": {
|
||||||
|
"declared_sha256": "43a53b00fab59fc62ad586d81f89f36293669c2f8ccedbfb1e3d70618d9af767",
|
||||||
|
"live_sha256": "0ca5b821666bdf262bb0742a0031a4f0f9f3d315ef85e951d6b9ce20e7563394",
|
||||||
|
"matches_declared": false
|
||||||
|
},
|
||||||
|
"policy_name": "platform-admin",
|
||||||
|
"role": {
|
||||||
|
"allowed_redirect_uris": [
|
||||||
|
"http://localhost:8250/oidc/callback",
|
||||||
|
"http://127.0.0.1:8250/oidc/callback",
|
||||||
|
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||||
|
"https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback",
|
||||||
|
"http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||||
|
],
|
||||||
|
"bound_audiences": [],
|
||||||
|
"bound_claims": {
|
||||||
|
"groups": [
|
||||||
|
"net-kingdom-admins"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bound_claims_type": "string",
|
||||||
|
"bound_subject": "",
|
||||||
|
"claim_mappings": {
|
||||||
|
"email": "email",
|
||||||
|
"preferred_username": "username"
|
||||||
|
},
|
||||||
|
"groups_claim": "groups",
|
||||||
|
"oidc_scopes": [
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email",
|
||||||
|
"groups"
|
||||||
|
],
|
||||||
|
"policies": [
|
||||||
|
"platform-admin",
|
||||||
|
"operator-custody"
|
||||||
|
],
|
||||||
|
"role_type": "oidc",
|
||||||
|
"token_bound_cidrs": [],
|
||||||
|
"token_explicit_max_ttl": 0,
|
||||||
|
"token_max_ttl": 0,
|
||||||
|
"token_no_default_policy": false,
|
||||||
|
"token_num_uses": 0,
|
||||||
|
"token_period": 0,
|
||||||
|
"token_policies": [
|
||||||
|
"platform-admin",
|
||||||
|
"operator-custody"
|
||||||
|
],
|
||||||
|
"token_ttl": 3600,
|
||||||
|
"token_type": "default",
|
||||||
|
"ttl": 3600,
|
||||||
|
"user_claim": "sub"
|
||||||
|
},
|
||||||
|
"role_path": "auth/netkingdom/role/platform-admin",
|
||||||
|
"schema": "railiance-platform.openbao-platform-admin-check.v1",
|
||||||
|
"self_capabilities": {
|
||||||
|
"auth/token/lookup-self": [
|
||||||
|
"read"
|
||||||
|
],
|
||||||
|
"auth/token/revoke-self": [
|
||||||
|
"update"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"status": "partial",
|
||||||
|
"steps": {
|
||||||
|
"capabilities_self": {
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
"read_attached_rules": {
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
"read_policy": {
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
"read_role": {
|
||||||
|
"ok": true
|
||||||
|
},
|
||||||
|
"token_lookup_self": {
|
||||||
|
"detail": [
|
||||||
|
"Usage: bao token <subcommand> [options] [args]"
|
||||||
|
],
|
||||||
|
"error": "exit_1",
|
||||||
|
"ok": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"token": null
|
||||||
|
}
|
||||||
|
|
@ -395,6 +395,21 @@ The browser callback URI must be present in both:
|
||||||
- KeyCape `openbao-admin` client redirect URIs; and
|
- KeyCape `openbao-admin` client redirect URIs; and
|
||||||
- OpenBao `auth/netkingdom/role/platform-admin` `allowed_redirect_uris`.
|
- OpenBao `auth/netkingdom/role/platform-admin` `allowed_redirect_uris`.
|
||||||
|
|
||||||
|
Declared state of the administrator login (verified live 2026-09-23,
|
||||||
|
`docs/evidence/2026-09-23-openbao-platform-admin-check.json`):
|
||||||
|
|
||||||
|
- role `auth/netkingdom/role/platform-admin`:
|
||||||
|
`openbao/auth/netkingdom-platform-admin-role.json`. It is written by
|
||||||
|
net-kingdom `sso-mfa/k8s/keycape/configure-openbao-oidc.sh`, which lacks
|
||||||
|
`operator-custody` and still lists retired `bao.coulomb.social` callbacks.
|
||||||
|
- policies `openbao/policies/platform-admin.hcl` and
|
||||||
|
`openbao/policies/operator-custody.hcl`, plus the built-in `default`
|
||||||
|
(`token_no_default_policy: false`). `default` grants `auth/token/revoke-self`
|
||||||
|
and `lookup-self`, so an attended session can always revoke itself.
|
||||||
|
- Re-check with the silent attended helper
|
||||||
|
`scripts/openbao-platform-admin-check.sh --receipt <new-file>`. Run it through
|
||||||
|
`scripts/openbao-attended-exec.py`. It only reads.
|
||||||
|
|
||||||
If the compatibility alias is kept enabled, also keep
|
If the compatibility alias is kept enabled, also keep
|
||||||
`http://127.0.0.1:18200/ui/vault/auth/keycape/oidc/callback` in the
|
`http://127.0.0.1:18200/ui/vault/auth/keycape/oidc/callback` in the
|
||||||
KeyCape client and `auth/keycape/role/platform-admin`.
|
KeyCape client and `auth/keycape/role/platform-admin`.
|
||||||
|
|
|
||||||
47
openbao/auth/netkingdom-platform-admin-role.json
Normal file
47
openbao/auth/netkingdom-platform-admin-role.json
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
{
|
||||||
|
"allowed_redirect_uris": [
|
||||||
|
"http://localhost:8250/oidc/callback",
|
||||||
|
"http://127.0.0.1:8250/oidc/callback",
|
||||||
|
"https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback",
|
||||||
|
"https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback",
|
||||||
|
"http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback"
|
||||||
|
],
|
||||||
|
"bound_audiences": [],
|
||||||
|
"bound_claims": {
|
||||||
|
"groups": [
|
||||||
|
"net-kingdom-admins"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"bound_claims_type": "string",
|
||||||
|
"bound_subject": "",
|
||||||
|
"claim_mappings": {
|
||||||
|
"email": "email",
|
||||||
|
"preferred_username": "username"
|
||||||
|
},
|
||||||
|
"groups_claim": "groups",
|
||||||
|
"oidc_scopes": [
|
||||||
|
"openid",
|
||||||
|
"profile",
|
||||||
|
"email",
|
||||||
|
"groups"
|
||||||
|
],
|
||||||
|
"policies": [
|
||||||
|
"platform-admin",
|
||||||
|
"operator-custody"
|
||||||
|
],
|
||||||
|
"role_type": "oidc",
|
||||||
|
"token_bound_cidrs": [],
|
||||||
|
"token_explicit_max_ttl": 0,
|
||||||
|
"token_max_ttl": 0,
|
||||||
|
"token_no_default_policy": false,
|
||||||
|
"token_num_uses": 0,
|
||||||
|
"token_period": 0,
|
||||||
|
"token_policies": [
|
||||||
|
"platform-admin",
|
||||||
|
"operator-custody"
|
||||||
|
],
|
||||||
|
"token_ttl": 3600,
|
||||||
|
"token_type": "default",
|
||||||
|
"ttl": 3600,
|
||||||
|
"user_claim": "sub"
|
||||||
|
}
|
||||||
30
openbao/policies/operator-custody.hcl
Normal file
30
openbao/policies/operator-custody.hcl
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
# operator-custody — human operators' own credentials.
|
||||||
|
#
|
||||||
|
# Distinct from platform-admin on purpose. platform-admin lists the mounts an
|
||||||
|
# administrator operates *on*; this is the one mount an administrator keeps
|
||||||
|
# things *in*. Separating them means custody can move independently of
|
||||||
|
# administration — which is exactly what the S6 trust stage (two-of-three
|
||||||
|
# independent recovery control) requires, and it cannot be done later if the
|
||||||
|
# grant is buried inside platform-admin.
|
||||||
|
#
|
||||||
|
# Holds: service account passwords, provider PATs, LLDAP admin bind,
|
||||||
|
# privacyIDEA pi-admin, machine identities. See
|
||||||
|
# net-kingdom/docs/platform-root-custody.md, "Operator Credential Custody".
|
||||||
|
#
|
||||||
|
# Does NOT hold, and must never: platform-root's own password, unseal shares,
|
||||||
|
# the break-glass password. Those live in the offline packet — putting them
|
||||||
|
# here is a circular dependency, since you would need this store to reach the
|
||||||
|
# credential that opens it.
|
||||||
|
|
||||||
|
path "operators/data/*" {
|
||||||
|
capabilities = ["create", "read", "update", "list"]
|
||||||
|
}
|
||||||
|
|
||||||
|
path "operators/metadata/*" {
|
||||||
|
capabilities = ["read", "list"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Delete is deliberately absent. A credential is retired by writing its
|
||||||
|
# successor — KV v2 keeps the prior version, which is the history that did not
|
||||||
|
# exist when the LLDAP predecessor was overwritten on 2026-08-27 and lost.
|
||||||
|
# Destroying a version is a separate, deliberate act under a broader identity.
|
||||||
|
|
@ -45,3 +45,11 @@ path "secret/*" {
|
||||||
path "tenants/*" {
|
path "tenants/*" {
|
||||||
capabilities = ["create", "read", "update", "delete", "list"]
|
capabilities = ["create", "read", "update", "delete", "list"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# reins/ -- per-rein credential secrets (glas-harness family), mount added
|
||||||
|
# 2026-07-27 by ops-mason (MASON-WP-0001-T05). Added here so platform-admin
|
||||||
|
# can complete paste-once-provision into any rein's path, matching every
|
||||||
|
# other KV mount already listed in this policy.
|
||||||
|
path "reins/*" {
|
||||||
|
capabilities = ["create", "read", "update", "delete", "list"]
|
||||||
|
}
|
||||||
|
|
|
||||||
4
scripts/openbao-platform-admin-check.sh
Executable file
4
scripts/openbao-platform-admin-check.sh
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Silent child for the governed attended login; results go to the receipt only.
|
||||||
|
set -euo pipefail
|
||||||
|
exec python3 "$(dirname "$0")/openbao_platform_admin_check.py" "$@" >/dev/null 2>&1
|
||||||
219
scripts/openbao_platform_admin_check.py
Executable file
219
scripts/openbao_platform_admin_check.py
Executable file
|
|
@ -0,0 +1,219 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Silent contained check of the platform-admin role, policy and self-revocation.
|
||||||
|
|
||||||
|
Read-only by default. Writes one non-secret receipt: the live role
|
||||||
|
configuration, the live policy compared with openbao/policies/platform-admin.hcl,
|
||||||
|
the session token's policy set (never its id or accessor), and the effective
|
||||||
|
capabilities on auth/token/revoke-self and auth/token/lookup-self.
|
||||||
|
|
||||||
|
With --apply-policy it additionally rewrites the live platform-admin policy
|
||||||
|
from the repo file, only when the live text differs, and verifies on readback.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROLE = 'auth/netkingdom/role/platform-admin'
|
||||||
|
POLICY = 'platform-admin'
|
||||||
|
POLICY_FILE = Path(__file__).resolve().parents[1] / 'openbao/policies/platform-admin.hcl'
|
||||||
|
SELF_PATHS = ('auth/token/revoke-self', 'auth/token/lookup-self')
|
||||||
|
NEEDED = {'auth/token/revoke-self': 'update', 'auth/token/lookup-self': 'read'}
|
||||||
|
ROLE_FIELDS = (
|
||||||
|
'role_type', 'user_claim', 'groups_claim', 'bound_claims', 'bound_claims_type',
|
||||||
|
'bound_audiences', 'bound_subject', 'claim_mappings', 'oidc_scopes',
|
||||||
|
'allowed_redirect_uris', 'token_policies', 'policies', 'token_no_default_policy',
|
||||||
|
'token_ttl', 'token_max_ttl', 'token_explicit_max_ttl', 'token_type',
|
||||||
|
'token_period', 'token_num_uses', 'token_bound_cidrs', 'ttl', 'max_ttl',
|
||||||
|
)
|
||||||
|
TOKENISH = re.compile(r'\b[a-z]{1,4}\.[A-Za-z0-9_-]{16,}|[A-Za-z0-9_-]{24,}')
|
||||||
|
TOKEN_FIELDS = ('policies', 'identity_policies', 'ttl', 'creation_ttl', 'type', 'path')
|
||||||
|
|
||||||
|
|
||||||
|
class Refused(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def require_attended():
|
||||||
|
if Path.home().parent.name != '.warden-attended-login' or os.getenv('BAO_TOKEN') or os.getenv('VAULT_TOKEN'):
|
||||||
|
raise Refused('attended_envelope_required')
|
||||||
|
|
||||||
|
|
||||||
|
def bao_json(command, *args):
|
||||||
|
"""Run `bao <command...> -format=json <args...>`; flags precede positionals."""
|
||||||
|
result = subprocess.run(['bao', *command.split(), '-format=json', *args],
|
||||||
|
capture_output=True, check=True, timeout=30)
|
||||||
|
return json.loads(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def digest(text):
|
||||||
|
return hashlib.sha256(text.strip().encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def read_role():
|
||||||
|
data = bao_json('read', ROLE)['data']
|
||||||
|
return {key: data[key] for key in ROLE_FIELDS if key in data}
|
||||||
|
|
||||||
|
|
||||||
|
def read_policy():
|
||||||
|
return bao_json('read', 'sys/policy/' + POLICY)['data']['rules']
|
||||||
|
|
||||||
|
|
||||||
|
def read_rules(name):
|
||||||
|
return bao_json('read', 'sys/policy/' + name)['data']['rules']
|
||||||
|
|
||||||
|
|
||||||
|
def read_attached_rules(role):
|
||||||
|
names = sorted(set(role.get('token_policies') or role.get('policies') or []) | {'default'})
|
||||||
|
return {name: read_rules(name) for name in names}
|
||||||
|
|
||||||
|
|
||||||
|
def read_token():
|
||||||
|
# `bao token lookup` rejects -format placement in some CLI builds; the API path does not.
|
||||||
|
data = bao_json('read', 'auth/token/lookup-self')['data']
|
||||||
|
return {key: data.get(key) for key in TOKEN_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
|
def read_capabilities():
|
||||||
|
data = bao_json('write', 'sys/capabilities-self', 'paths=' + ','.join(SELF_PATHS))['data']
|
||||||
|
return {path: sorted(data.get(path, [])) for path in SELF_PATHS}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_policy():
|
||||||
|
subprocess.run(['bao', 'policy', 'write', POLICY, str(POLICY_FILE)],
|
||||||
|
capture_output=True, check=True, timeout=30)
|
||||||
|
|
||||||
|
|
||||||
|
def assess(role, rules, token, caps):
|
||||||
|
declared = POLICY_FILE.read_text(encoding='utf-8')
|
||||||
|
policies = set(token.get('policies') or []) | set(token.get('identity_policies') or [])
|
||||||
|
missing = sorted(path for path, cap in NEEDED.items()
|
||||||
|
if cap not in caps[path] and 'root' not in caps[path])
|
||||||
|
return {
|
||||||
|
'role': role,
|
||||||
|
'policy': {
|
||||||
|
'live_sha256': digest(rules), 'declared_sha256': digest(declared),
|
||||||
|
'matches_declared': digest(rules) == digest(declared),
|
||||||
|
},
|
||||||
|
'token': {**token, 'has_default_policy': 'default' in policies},
|
||||||
|
'self_capabilities': caps,
|
||||||
|
'self_capabilities_missing': missing,
|
||||||
|
'self_revocation_permitted': not missing,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_receipt(path, status, **extra):
|
||||||
|
fd = os.open(Path(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||||
|
body = {
|
||||||
|
'schema': 'railiance-platform.openbao-platform-admin-check.v1',
|
||||||
|
'observed_at': datetime.now(timezone.utc).isoformat(),
|
||||||
|
'role_path': ROLE, 'policy_name': POLICY, 'status': status,
|
||||||
|
'credential_values_emitted': False, **extra,
|
||||||
|
}
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8') as handle:
|
||||||
|
json.dump(body, handle, indent=2, sort_keys=True)
|
||||||
|
handle.write('\n')
|
||||||
|
|
||||||
|
|
||||||
|
def classify(error):
|
||||||
|
if isinstance(error, Refused):
|
||||||
|
return str(error)
|
||||||
|
if isinstance(error, subprocess.CalledProcessError):
|
||||||
|
return 'bao_policy_write_failed' if 'policy' in error.cmd else 'bao_read_failed'
|
||||||
|
return 'contained_operation_failed'
|
||||||
|
|
||||||
|
|
||||||
|
def error_summary(error):
|
||||||
|
"""Keep only bao's status and error lines; they carry no token material."""
|
||||||
|
if not isinstance(error, subprocess.CalledProcessError):
|
||||||
|
return {'error': type(error).__name__}
|
||||||
|
text = (error.stderr or b'').decode('utf-8', 'replace')
|
||||||
|
lines = [line.strip() for line in text.splitlines()
|
||||||
|
if line.strip().startswith(('Code:', '* ', 'URL:'))]
|
||||||
|
if not lines: # local CLI error: first line only, token-shaped strings redacted
|
||||||
|
first = next((line.strip() for line in text.splitlines() if line.strip()), '')
|
||||||
|
lines = [TOKENISH.sub('[redacted]', first)[:200]] if first else []
|
||||||
|
return {'error': 'exit_%s' % error.returncode, 'detail': lines[:4]}
|
||||||
|
|
||||||
|
|
||||||
|
def step(steps, name, func):
|
||||||
|
try:
|
||||||
|
value = func()
|
||||||
|
steps[name] = {'ok': True}
|
||||||
|
return value
|
||||||
|
except Exception as error: # record and continue; one run should say everything
|
||||||
|
steps[name] = {'ok': False, **error_summary(error)}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def collect():
|
||||||
|
steps = {}
|
||||||
|
role = step(steps, 'read_role', read_role)
|
||||||
|
rules = step(steps, 'read_policy', read_policy)
|
||||||
|
token = step(steps, 'token_lookup_self', read_token)
|
||||||
|
caps = step(steps, 'capabilities_self', read_capabilities)
|
||||||
|
result = {'steps': steps}
|
||||||
|
if role is not None:
|
||||||
|
result['attached_policy_rules'] = step(steps, 'read_attached_rules',
|
||||||
|
lambda: read_attached_rules(role))
|
||||||
|
if None not in (role, rules, token, caps):
|
||||||
|
result.update(assess(role, rules, token, caps))
|
||||||
|
else:
|
||||||
|
result.update({'role': role, 'token': token, 'self_capabilities': caps,
|
||||||
|
'policy': None if rules is None else {
|
||||||
|
'live_sha256': digest(rules),
|
||||||
|
'declared_sha256': digest(POLICY_FILE.read_text(encoding='utf-8')),
|
||||||
|
'matches_declared': digest(rules) == digest(POLICY_FILE.read_text(encoding='utf-8'))}})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def run(apply=False):
|
||||||
|
result = collect()
|
||||||
|
changed = False
|
||||||
|
policy = result.get('policy')
|
||||||
|
if apply and policy and not policy['matches_declared']:
|
||||||
|
apply_policy()
|
||||||
|
changed = True
|
||||||
|
result = collect()
|
||||||
|
if not (result.get('policy') or {}).get('matches_declared'):
|
||||||
|
raise Refused('readback_policy_mismatch')
|
||||||
|
return result, changed
|
||||||
|
|
||||||
|
|
||||||
|
def parse(argv):
|
||||||
|
receipt, apply, args = None, False, list(argv)
|
||||||
|
while args:
|
||||||
|
if args[0] == '--receipt' and len(args) > 1:
|
||||||
|
receipt, args = args[1], args[2:]
|
||||||
|
elif args[0] == '--apply-policy':
|
||||||
|
apply, args = True, args[1:]
|
||||||
|
else:
|
||||||
|
raise SystemExit(2)
|
||||||
|
if not receipt:
|
||||||
|
raise SystemExit(2)
|
||||||
|
return receipt, apply
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
receipt, apply = parse(argv)
|
||||||
|
try:
|
||||||
|
require_attended()
|
||||||
|
result, changed = run(apply)
|
||||||
|
complete = all(item['ok'] for item in result['steps'].values())
|
||||||
|
write_receipt(receipt, 'checked' if complete else 'partial',
|
||||||
|
applied=apply, changed=changed, **result)
|
||||||
|
return 0
|
||||||
|
except Exception as error:
|
||||||
|
try:
|
||||||
|
write_receipt(receipt, classify(error), applied=apply)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main(sys.argv[1:]))
|
||||||
105
tests/test_openbao_platform_admin_check.py
Normal file
105
tests/test_openbao_platform_admin_check.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
'check', Path(__file__).resolve().parents[1] / 'scripts/openbao_platform_admin_check.py')
|
||||||
|
m = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(m)
|
||||||
|
|
||||||
|
DECLARED = m.POLICY_FILE.read_text(encoding='utf-8')
|
||||||
|
FULL = {p: ['create', 'delete', 'list', 'read', 'sudo', 'update'] for p in m.SELF_PATHS}
|
||||||
|
|
||||||
|
|
||||||
|
def token(**extra):
|
||||||
|
return dict({'policies': ['default', 'platform-admin'], 'identity_policies': None,
|
||||||
|
'ttl': 3500, 'creation_ttl': 3600, 'type': 'service',
|
||||||
|
'path': 'auth/netkingdom/oidc/callback'}, **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matching_policy_and_capabilities_pass():
|
||||||
|
result = m.assess({'role_type': 'oidc'}, DECLARED + '\n', token(), FULL)
|
||||||
|
assert result['policy']['matches_declared']
|
||||||
|
assert result['self_revocation_permitted']
|
||||||
|
assert result['token']['has_default_policy']
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_self_capabilities_are_reported():
|
||||||
|
caps = {'auth/token/revoke-self': ['deny'], 'auth/token/lookup-self': ['read']}
|
||||||
|
result = m.assess({}, 'path "x" {}', token(policies=['platform-admin']), caps)
|
||||||
|
assert not result['policy']['matches_declared']
|
||||||
|
assert result['self_capabilities_missing'] == ['auth/token/revoke-self']
|
||||||
|
assert not result['token']['has_default_policy']
|
||||||
|
|
||||||
|
|
||||||
|
def test_bao_json_places_format_flag_before_positionals(monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
class Done:
|
||||||
|
stdout = b'{"data": {}}'
|
||||||
|
|
||||||
|
monkeypatch.setattr(m.subprocess, 'run', lambda cmd, **kw: seen.append(cmd) or Done())
|
||||||
|
m.bao_json('read', 'auth/token/lookup-self')
|
||||||
|
m.bao_json('write', 'sys/capabilities-self', 'paths=a,b')
|
||||||
|
assert seen == [['bao', 'read', '-format=json', 'auth/token/lookup-self'],
|
||||||
|
['bao', 'write', '-format=json', 'sys/capabilities-self', 'paths=a,b']]
|
||||||
|
|
||||||
|
|
||||||
|
def test_token_id_is_never_kept(monkeypatch):
|
||||||
|
monkeypatch.setattr(m, 'bao_json', lambda *a: {'data': {'id': 'SECRET', 'accessor': 'ACC', 'policies': ['default']}})
|
||||||
|
kept = m.read_token()
|
||||||
|
assert 'SECRET' not in json.dumps(kept) and 'ACC' not in json.dumps(kept)
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_only_when_drifted_and_verified(monkeypatch):
|
||||||
|
live = {'rules': 'path "old" {}'}
|
||||||
|
writes = []
|
||||||
|
monkeypatch.setattr(m, 'read_role', lambda: {})
|
||||||
|
monkeypatch.setattr(m, 'read_policy', lambda: live['rules'])
|
||||||
|
monkeypatch.setattr(m, 'read_token', token)
|
||||||
|
monkeypatch.setattr(m, 'read_capabilities', lambda: FULL)
|
||||||
|
monkeypatch.setattr(m, 'apply_policy', lambda: writes.append(1) or live.update(rules=DECLARED))
|
||||||
|
result, changed = m.run(apply=True)
|
||||||
|
assert changed and writes == [1] and result['policy']['matches_declared']
|
||||||
|
_, changed = m.run(apply=True)
|
||||||
|
assert not changed and writes == [1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refuses_outside_attended_envelope(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv('HOME', str(tmp_path))
|
||||||
|
receipt = tmp_path / 'r.json'
|
||||||
|
assert m.main(['--receipt', str(receipt)]) == 1
|
||||||
|
assert json.loads(receipt.read_text())['status'] == 'attended_envelope_required'
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_step_is_recorded_and_others_continue(monkeypatch):
|
||||||
|
def denied():
|
||||||
|
raise m.subprocess.CalledProcessError(
|
||||||
|
2, ['bao'], stderr=b'Error\nURL: GET http://127.0.0.1:18200/v1/auth/token/lookup-self\nCode: 403. Errors:\n\n* permission denied\n')
|
||||||
|
monkeypatch.setattr(m, 'read_role', lambda: {'role_type': 'oidc'})
|
||||||
|
monkeypatch.setattr(m, 'read_policy', lambda: DECLARED)
|
||||||
|
monkeypatch.setattr(m, 'read_token', denied)
|
||||||
|
monkeypatch.setattr(m, 'read_capabilities', lambda: FULL)
|
||||||
|
result, changed = m.run()
|
||||||
|
assert not changed
|
||||||
|
assert result['steps']['token_lookup_self'] == {
|
||||||
|
'ok': False, 'error': 'exit_2',
|
||||||
|
'detail': ['URL: GET http://127.0.0.1:18200/v1/auth/token/lookup-self',
|
||||||
|
'Code: 403. Errors:', '* permission denied']}
|
||||||
|
assert result['steps']['read_role']['ok'] and result['policy']['matches_declared']
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_error_line_is_kept_with_token_shapes_redacted():
|
||||||
|
error = m.subprocess.CalledProcessError(
|
||||||
|
1, ['bao'], stderr=b'error looking up token hvs.CAESIabcdefghijklmnopqrstu: bad\n')
|
||||||
|
detail = m.error_summary(error)['detail']
|
||||||
|
assert detail == ['error looking up token [redacted]: bad']
|
||||||
|
|
||||||
|
|
||||||
|
def test_attached_rules_include_default(monkeypatch):
|
||||||
|
monkeypatch.setattr(m, 'read_rules', lambda name: 'rules-' + name)
|
||||||
|
rules = m.read_attached_rules({'token_policies': ['platform-admin', 'operator-custody']})
|
||||||
|
assert rules == {'default': 'rules-default', 'operator-custody': 'rules-operator-custody',
|
||||||
|
'platform-admin': 'rules-platform-admin'}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue