spec: runbook-pack/v0.2 id: nk.object-storage-sts title: Vend temporary S3 credentials from a NetKingdom identity token owner: net-kingdom outcome: You can state who owns each hop from identity token to S3 credential, have built and checked a scoped request, a normalized response, a stable deny and a refreshing consumer setup offline, and know whether a live credential-vending service exists to try it against. engine: native framing: - Today the pattern is an architecture baseline (NK-WP-0007); no credential-vending service is deployed. The offline stages teach and check the contract; they do not prove a live service. - Never put a real access key, secret key, session token or IAM token in a file, chat, State Hub or a log. Every sample here is fabricated and short-lived by construction. - Consumers never fall back to root or long-lived credentials. If refresh fails, stop writes before expiration. - local-identity tokens are for development only; production issuers are key-cape or Keycloak. actors: - id: learner role: Learner description: Works through the contract offline, and later against a live service with their own identity. privileges: [read-docs, run-local-checks] datapoints: - id: workdir label: Scratch directory type: path default: /tmp/nk-sts-tutorial help: Holds only fabricated sample files. dimensions: {actor: learner, kind: location, subsystem: workstation} ask_in: request - id: tenant label: Tenant type: string default: tenant:coulomb pattern: "tenant:[a-z0-9:-]+" dimensions: {actor: learner, kind: identity, subsystem: flex-auth} ask_in: request - id: bucket label: Bucket type: string default: artifact-store-prod pattern: "[a-z0-9-]+" dimensions: {kind: target, subsystem: object-storage} ask_in: request - id: prefix label: Prefix type: string default: tenant/coulomb/packages/ pattern: "[a-z0-9/_-]+/" help: The preferred grant boundary; must end with a slash. dimensions: {kind: target, subsystem: object-storage} ask_in: request - id: ttl_seconds label: Requested TTL in seconds type: string default: "1800" pattern: "[0-9]+" help: Workload default 1800; interactive 900; normal maximum 3600. dimensions: {kind: policy, subsystem: flex-auth} ask_in: request - id: service_state label: Is a credential-vending service deployed? type: enum values: [deployed, not-deployed] default: not-deployed help: Decides which way the live stage is recommended. As of this pack, none is deployed. dimensions: {kind: state, subsystem: credential-vending} ask_in: live - id: endpoint label: Vending endpoint type: string default: "" help: Only when a service is deployed; a private or tunnelled URL. dimensions: {kind: target, subsystem: credential-vending} ask_in: live stages: - id: understand title: Know the hops and their owners goal: You can name the owner of each hop and where authority is decided. steps: - id: read-baseline title: Read the architecture baseline actor: learner owner: net-kingdom description: "Read docs/object-storage-sts-credential-vending.md: the core flow, trust boundaries, flex-auth vocabulary, TTL policy and failure modes." idempotent: idempotent verify: done_when: You can trace token -> vending service -> flex-auth decision -> backend exchange -> temporary credentials -> consumer expect: manual - id: ownership title: State who owns what actor: learner owner: net-kingdom requires: [read-baseline] description: "IAM Profile and claims: NetKingdom (issuer key-cape or Keycloak). Decision and vocabulary: flex-auth. Parent credentials, broker config and leases: OpenBao via railiance-platform. Backend STS: railiance-platform. Refresh behaviour: the consumer, for example artifact-store." idempotent: idempotent verify: done_when: You can say why OpenBao is not the authorization decision point expect: manual gates: - id: hops-understood title: The hops and owners are understood goal: clear-ownership blocking: true downstream_risk: Credentials get requested from, or blamed on, the wrong owner. checks: - done_when: Each hop has a named owner expect: manual - id: request title: Build and check a scoped request goal: A request file that names one tenant, one prefix, canonical actions and a bounded TTL passes the contract check. requires: ["gate:hops-understood"] steps: - id: compose-request title: Write a sample request actor: learner owner: net-kingdom uses: [workdir, tenant, bucket, prefix, ttl_seconds] command: | mkdir -p {{workdir}} && chmod 700 {{workdir}} python3 - <<'PY' import json json.dump({ "protected_system_id": "object-storage:{{bucket}}", "tenant_id": "{{tenant}}", "bucket": "{{bucket}}", "prefix": "{{prefix}}", "actions": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"], "ttl_seconds": int("{{ttl_seconds}}"), "purpose": "tutorial package upload", "correlation_id": "01TUTORIAL0000000000000001", }, open("{{workdir}}/request.json", "w"), indent=2) PY idempotent: idempotent idempotent_note: Overwrites the same sample file. rollback: Delete the scratch directory. verify: done_when: The request file exists command: test -s {{workdir}}/request.json expect: exit-0 - id: check-request title: Check the request against the contract actor: learner owner: net-kingdom requires: [compose-request] uses: [workdir] command: python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/request.json idempotent: idempotent verify: done_when: The check prints OK command: python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/request.json expect: output-contains contains: "OK" - id: refuse-bad-request title: See an over-long TTL and an unknown action refused actor: learner owner: net-kingdom requires: [check-request] uses: [workdir] description: The check must refuse a two-hour TTL and a made-up action, the way flex-auth policy would. command: | python3 - <<'PY' import json r = json.load(open("{{workdir}}/request.json")) r["ttl_seconds"] = 7200 json.dump(r, open("{{workdir}}/bad-ttl.json", "w")) r["ttl_seconds"] = 900 r["actions"] = ["s3:*"] json.dump(r, open("{{workdir}}/bad-action.json", "w")) PY ! python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/bad-ttl.json && ! python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/bad-action.json idempotent: idempotent verify: done_when: Both bad requests are refused command: "! python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/bad-ttl.json && ! python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/bad-action.json" expect: exit-0 gates: - id: request-conforms title: The request conforms and bad ones are refused goal: least-privilege blocking: true downstream_risk: A vague request (wildcard actions, long TTL, object-wide scope) would be over-granted. checks: - done_when: The good request passes command: python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/request.json expect: exit-0 - done_when: The over-long TTL is refused command: "! python3 runbooks/object-storage-sts/check_sts.py request {{workdir}}/bad-ttl.json" expect: exit-0 - id: response title: Read a normalized response and a deny goal: You can tell a well-formed temporary grant from a deny, and see that a deny carries no credentials. requires: ["gate:request-conforms"] steps: - id: compose-response title: Write a fabricated allow response actor: learner owner: net-kingdom uses: [workdir, tenant, bucket, prefix] description: Every value is fabricated; the expiration is thirty minutes from now. command: | python3 - <<'PY' import json from datetime import datetime, timedelta, timezone exp = (datetime.now(timezone.utc) + timedelta(minutes=30)).strftime("%Y-%m-%dT%H:%M:%SZ") json.dump({ "credentials": {"access_key_id": "TUTORIALKEYID", "secret_access_key": "fabricated", "session_token": "fabricated-session", "expiration": exp}, "scope": {"protected_system_id": "object-storage:{{bucket}}", "tenant_id": "{{tenant}}", "bucket": "{{bucket}}", "prefix": "{{prefix}}", "actions": ["s3:GetObject", "s3:ListBucket"]}, "lease": {"ttl_seconds": 1800, "renewable": False, "backend": "tutorial", "openbao_lease_id": None}, "decision": {"decision_id": "dec_tutorial_1", "policy_package": "object-storage-{{bucket}}@tutorial", "obligations": ["checksum-required"], "audit_correlation_id": "01TUTORIAL0000000000000001"}, }, open("{{workdir}}/response.json", "w"), indent=2) json.dump({"error": "credential_denied", "reason_code": "prefix_not_registered_for_tenant", "decision_id": "dec_tutorial_2", "audit_correlation_id": "01TUTORIAL0000000000000002"}, open("{{workdir}}/deny.json", "w"), indent=2) PY idempotent: idempotent verify: done_when: Both sample files exist command: test -s {{workdir}}/response.json && test -s {{workdir}}/deny.json expect: exit-0 - id: check-response title: Check the response stays inside the request actor: learner owner: flex-auth requires: [compose-response] uses: [workdir] description: The granted actions may be fewer than requested, never more; scope must match; the credential must be temporary and unexpired; the lease must not exceed one hour. command: python3 runbooks/object-storage-sts/check_sts.py response {{workdir}}/response.json {{workdir}}/request.json idempotent: idempotent verify: done_when: The check prints OK command: python3 runbooks/object-storage-sts/check_sts.py response {{workdir}}/response.json {{workdir}}/request.json expect: output-contains contains: "OK" - id: check-deny title: Check the deny has a stable reason and no credentials actor: learner owner: flex-auth requires: [compose-response] uses: [workdir] command: python3 runbooks/object-storage-sts/check_sts.py deny {{workdir}}/deny.json idempotent: idempotent verify: done_when: The check prints OK command: python3 runbooks/object-storage-sts/check_sts.py deny {{workdir}}/deny.json expect: output-contains contains: "OK" gates: - id: contract-shapes title: Allow and deny shapes conform goal: auditable-decisions blocking: true downstream_risk: A consumer that cannot tell a deny from a grant, or an over-broad grant from a scoped one, will misuse credentials. checks: - done_when: The allow response is inside the request scope command: python3 runbooks/object-storage-sts/check_sts.py response {{workdir}}/response.json {{workdir}}/request.json expect: exit-0 - done_when: The deny carries a stable reason code and no credentials command: python3 runbooks/object-storage-sts/check_sts.py deny {{workdir}}/deny.json expect: exit-0 - id: consume title: Consume it through credential_process goal: An SDK-style consumer obtains fresh temporary credentials and never holds a long-lived key. requires: ["gate:contract-shapes"] steps: - id: credproc-script title: Write a stand-in credential_process actor: learner owner: net-kingdom uses: [workdir] description: "A real one calls the vending service with the caller's token. This stand-in prints fabricated credentials with a fresh expiry so you can see the shape the AWS SDKs expect." command: | cat > {{workdir}}/credproc.sh <<'SH' #!/bin/sh exp=$(date -u -d '+30 minutes' +%Y-%m-%dT%H:%M:%SZ) printf '{"Version":1,"AccessKeyId":"TUTORIALKEYID","SecretAccessKey":"fabricated","SessionToken":"fabricated-session","Expiration":"%s"}\n' "$exp" SH chmod 700 {{workdir}}/credproc.sh idempotent: idempotent verify: done_when: The script exists and is executable command: test -x {{workdir}}/credproc.sh expect: exit-0 - id: check-credproc title: Check the credential_process output actor: learner owner: artifact-store requires: [credproc-script] uses: [workdir] command: "{{workdir}}/credproc.sh | python3 runbooks/object-storage-sts/check_sts.py credproc" idempotent: idempotent verify: done_when: The check prints OK command: "{{workdir}}/credproc.sh | python3 runbooks/object-storage-sts/check_sts.py credproc" expect: output-contains contains: "OK" - id: sdk-config title: See how an SDK profile points at it actor: learner owner: artifact-store requires: [check-credproc] uses: [workdir] description: "An AWS-style profile uses credential_process; the SDK re-runs it before Expiration. Nothing is written to your real ~/.aws." command: printf '[profile tutorial]\ncredential_process = %s/credproc.sh\n' "{{workdir}}" idempotent: idempotent verify: done_when: You can say what the consumer does when the process fails (stop writes before expiry, retry with backoff, never fall back to root) expect: manual gates: - id: consumer-refreshes title: The consumer path yields temporary credentials goal: short-lived-credentials blocking: true downstream_risk: A consumer wired to a static key defeats the pattern. checks: - done_when: credential_process output is well-formed, temporary and unexpired command: "{{workdir}}/credproc.sh | python3 runbooks/object-storage-sts/check_sts.py credproc" expect: exit-0 - id: live title: Try it against a live service, if one exists goal: The live status is recorded honestly; if a service is deployed, a real request is denied or granted with an audit id. requires: ["gate:consumer-refreshes"] steps: - id: live-or-not title: Choose by whether a service is deployed actor: learner owner: railiance-platform uses: [service_state, endpoint, workdir] description: As of this pack no credential-vending service is deployed, so the expected answer is "not deployed". Do not stand one up from this repo; deployment belongs to railiance-platform. If deployed, enter your IAM token only at the hidden prompt; expect a grant or a deny, each with an audit correlation id. If not deployed, record that the offline checks passed and the live path is unproven. idempotent: idempotent auto_confirm: manual-only one_of: - id: call-service title: Call the deployed service with your own token recommended_when: {service_state: [deployed]} command: | printf "IAM token: " >&2; read -rs IAM_TOKEN; printf "\n" >&2 curl -s -X POST {{endpoint}}/v1/object-storage/credentials -H "Authorization: Bearer $IAM_TOKEN" -H 'Content-Type: application/json' --data @{{workdir}}/request.json | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("error") or "granted", d.get("reason_code",""), (d.get("decision") or d).get("audit_correlation_id"))' unset IAM_TOKEN verify: done_when: The response is a grant or a deny with an audit correlation id, and you did not see a secret expect: manual - id: not-deployed title: Record that no service is deployed yet recommended_when: {service_state: [not-deployed]} verify: done_when: The live status is recorded as not deployed expect: manual gates: - id: live-status-recorded title: The live status is on record goal: honest-evidence blocking: true downstream_risk: The offline pass gets read as proof of a running service. checks: - done_when: Either a real request was answered with an audit id, or "not deployed" is recorded expect: manual