Manifest the lifecycle engine as a library, a one-stage CLI, and an in-process service.
Planning stays off the store client. The process binds one stage credential at start, serve runs the same lifecycle library, and a local allow flag does not authorize a protected call. Assistant: grok Assistant-Session: 01a1609f-72b8-7222-9dff-fae300115e42
This commit is contained in:
parent
3e3f8fc629
commit
06dfe12c18
8 changed files with 530 additions and 26 deletions
39
SCOPE.md
39
SCOPE.md
|
|
@ -10,13 +10,17 @@
|
||||||
|
|
||||||
## One-liner
|
## One-liner
|
||||||
|
|
||||||
secrets-engine is the Lifecycle engine for cataloged credential work: a
|
secrets-engine is the Lifecycle engine for cataloged credential work. Its
|
||||||
decision-gated Python CLI that validates non-secret lane metadata and
|
library validates non-secret lane metadata and orchestrates narrowly scoped
|
||||||
orchestrates narrowly scoped OpenBao policy, AppRole, provisioning,
|
OpenBao policy, AppRole, provisioning, verification, delivery, routing,
|
||||||
verification, delivery, routing, handoff, and revocation actions.
|
handoff, and revocation actions. The CLI is that library in one process. The
|
||||||
|
process binds one stage, `build`, `test`, or `production`, and receives that
|
||||||
|
stage's credential at start. `serve` keeps the same library running for that
|
||||||
|
stage. Other engines may use OpenBao for other capabilities.
|
||||||
|
|
||||||
It is a deterministic API over OpenBao, not a vault, not a policy decision
|
It is a deterministic processor over OpenBao, not a vault, not a policy
|
||||||
point, not an identity provider, and not a general secrets API.
|
decision point, not an identity provider, and not a general secrets API.
|
||||||
|
Exec-time delivery remains the default for a one-task caller.
|
||||||
|
|
||||||
## Implemented Capabilities
|
## Implemented Capabilities
|
||||||
|
|
||||||
|
|
@ -225,9 +229,25 @@ through the merge-safe write path. Persistent overlay states
|
||||||
directory; they do not recreate OpenBao objects and do not rotate provider
|
directory; they do not recreate OpenBao objects and do not rotate provider
|
||||||
credentials.
|
credentials.
|
||||||
|
|
||||||
|
## One-stage process
|
||||||
|
|
||||||
|
- Planning stays in the library and does not open the store. Apply still uses
|
||||||
|
the OpenBao client.
|
||||||
|
- `instance` prints the one bound stage. The injected credential is not
|
||||||
|
printed, returned, or written to Git, State Hub, logs, or the workplan.
|
||||||
|
- A second stage credential in the same process refuses startup.
|
||||||
|
- `serve` keeps the lifecycle library the CLI uses running for that one stage.
|
||||||
|
It is the in-process manifestation of this library.
|
||||||
|
- `--allow` and `SECRETS_ENGINE_LOCAL_ALLOW` do not authorize apply or the
|
||||||
|
service. Permission to use an instance is the access-engine claim in
|
||||||
|
`docs/engine-use-claim.md`. Protected side effects still require a decision
|
||||||
|
record or the published unreachable-engine stance.
|
||||||
|
|
||||||
## CLI Surface
|
## CLI Surface
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
secrets-engine instance
|
||||||
|
secrets-engine serve <catalog-id> --operation suspend|deactivate|destroy
|
||||||
secrets-engine catalog list|show
|
secrets-engine catalog list|show
|
||||||
secrets-engine decision inspect
|
secrets-engine decision inspect
|
||||||
secrets-engine plan
|
secrets-engine plan
|
||||||
|
|
@ -262,7 +282,8 @@ metadata. `secrets-engine wrap` implements response-wrapped operator handoff.
|
||||||
|
|
||||||
## Not Implemented
|
## Not Implemented
|
||||||
|
|
||||||
- A service API, daemon, UI, queue, scheduler, or remote multi-user service.
|
- A remote multi-user API, UI, queue, or scheduler. `serve` is the kept-running
|
||||||
|
in-process manifestation of this library for one stage.
|
||||||
- Engine-action authority for the `service-jwt` identity. The platform JWT
|
- Engine-action authority for the `service-jwt` identity. The platform JWT
|
||||||
role is login-only, and its CCR negative checks (RPF-WP-0035-T02) are open.
|
role is login-only, and its CCR negative checks (RPF-WP-0035-T02) are open.
|
||||||
- Provider-side / workload consumer rotation. Overlay lane state is local and
|
- Provider-side / workload consumer rotation. Overlay lane state is local and
|
||||||
|
|
@ -387,7 +408,9 @@ title: AppRole-scoped exec delivery
|
||||||
description: Fetches one declared KV field through a lane AppRole and injects it into a child
|
description: Fetches one declared KV field through a lane AppRole and injects it into a child
|
||||||
process using exec-env or a temporary npm config, with output redaction, explicit scoped-token
|
process using exec-env or a temporary npm config, with output redaction, explicit scoped-token
|
||||||
self-revocation, and non-secret cleanup evidence. Adapters: exec-env, npm-config, exec-file;
|
self-revocation, and non-secret cleanup evidence. Adapters: exec-env, npm-config, exec-file;
|
||||||
`wrap` provides response-wrapped operator handoff. This is CLI-local delivery; service delivery is absent.
|
`wrap` provides response-wrapped operator handoff. Exec-time delivery remains
|
||||||
|
the default for a one-task caller. `serve` keeps the lifecycle library running
|
||||||
|
for one injected stage and does not deliver values.
|
||||||
keywords: [secrets, delivery, exec, npm, injection, redaction, openbao]
|
keywords: [secrets, delivery, exec, npm, injection, redaction, openbao]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
| workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
| workplan | SECRETS-WP-0013 | proposed | — | workplans/SECRETS-WP-0013-task-scoped-openbao-execution.md |
|
||||||
| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
| workplan | SECRETS-WP-0014 | active | — | workplans/SECRETS-WP-0014-pdp-transport-and-responder-authentication.md |
|
||||||
| workplan | SECRETS-WP-0015 | active | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
| workplan | SECRETS-WP-0015 | active | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
||||||
| workplan | SECRETS-WP-0016 | ready | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| workplan | SECRETS-WP-0016 | finished | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md |
|
| task | SECRETS-WP-ADHOC-2026-08-21-T01 | done | — | workplans/ADHOC-2026-08-21.md |
|
||||||
| task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
|
| task | SECRETS-WP-ADHOC-2026-08-23-T01 | done | — | workplans/ADHOC-2026-08-23.md |
|
||||||
| task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md |
|
| task | SECRETS-WP-0001-T01 | done | — | workplans/SECRETS-WP-0001-statehub-bootstrap.md |
|
||||||
|
|
@ -98,11 +98,11 @@
|
||||||
| task | SECRETS-WP-0015-T03 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
| task | SECRETS-WP-0015-T03 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
||||||
| task | SECRETS-WP-0015-T04 | wait | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
| task | SECRETS-WP-0015-T04 | wait | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
||||||
| task | SECRETS-WP-0015-T05 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
| task | SECRETS-WP-0015-T05 | done | — | workplans/SECRETS-WP-0015-original-keycape-recovery-selector.md |
|
||||||
| task | SECRETS-WP-0016-T01 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| task | SECRETS-WP-0016-T01 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| task | SECRETS-WP-0016-T02 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| task | SECRETS-WP-0016-T02 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| task | SECRETS-WP-0016-T03 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| task | SECRETS-WP-0016-T03 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| task | SECRETS-WP-0016-T04 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| task | SECRETS-WP-0016-T04 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| task | SECRETS-WP-0016-T05 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
| task | SECRETS-WP-0016-T05 | done | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||||
| intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md |
|
| intake | SECRETS-IN-0001 | closed | — | intakes/intakes.md |
|
||||||
| intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md |
|
| intake | SECRETS-IN-0002 | closed | — | intakes/intakes.md |
|
||||||
| intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md |
|
| intake | SECRETS-IN-0003 | closed | — | intakes/intakes.md |
|
||||||
|
|
|
||||||
22
docs/engine-use-claim.md
Normal file
22
docs/engine-use-claim.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Use claim consumed by secrets-engine
|
||||||
|
|
||||||
|
Permission to start or call one secrets-engine stage instance is an
|
||||||
|
access-engine decision. This engine consumes that claim. It does not decide it
|
||||||
|
and it does not store a credential for it.
|
||||||
|
|
||||||
|
## Claim
|
||||||
|
|
||||||
|
- Decider: access-engine.
|
||||||
|
- Subject: an operator allowed to use one stage instance.
|
||||||
|
- Object: one secrets-engine process bound to exactly one stage.
|
||||||
|
- Stage: `build`, `test`, or `production`.
|
||||||
|
- Action: use.
|
||||||
|
- Effect: the operator may call that instance for the bound stage.
|
||||||
|
|
||||||
|
The stage credential is injected into the process at start and stays there.
|
||||||
|
Use of the instance does not confer that credential. Delegation of a task
|
||||||
|
transfers neither the use decision nor the injected credential.
|
||||||
|
|
||||||
|
`--allow` and `SECRETS_ENGINE_LOCAL_ALLOW` are not this claim. They do not
|
||||||
|
authorize apply or the service. A protected side effect still requires a
|
||||||
|
decision record or the published unreachable-engine stance.
|
||||||
|
|
@ -57,6 +57,7 @@ from secrets_engine.lane_state import (
|
||||||
)
|
)
|
||||||
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
from secrets_engine.pep_stance import apply_unreachable_engine_stance, with_decision
|
||||||
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file
|
from secrets_engine.openbao import OpenBaoClient, accessor_fingerprint, read_strict_token_file
|
||||||
|
from secrets_engine.stage_binding import StartupError, load_stage_binding
|
||||||
from secrets_engine.plan import build_plan
|
from secrets_engine.plan import build_plan
|
||||||
from secrets_engine.provision import provision_from_file, provision_generated
|
from secrets_engine.provision import provision_from_file, provision_generated
|
||||||
from secrets_engine.routing import route_lane
|
from secrets_engine.routing import route_lane
|
||||||
|
|
@ -281,7 +282,47 @@ def cmd_plan(cfg: Config, args) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_instance(cfg: Config, args) -> int:
|
||||||
|
"""Report the one stage bound at process start. Never prints the credential."""
|
||||||
|
del cfg
|
||||||
|
binding = getattr(args, "stage_binding", None)
|
||||||
|
if binding is None:
|
||||||
|
raise StartupError("startup refused: no stage credential is present")
|
||||||
|
print(binding.public())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_serve(cfg: Config, args) -> int:
|
||||||
|
"""Keep the lifecycle library running for the one injected stage."""
|
||||||
|
from secrets_engine.catalog import get_entry
|
||||||
|
from secrets_engine.service import local_allow_requested, run_service
|
||||||
|
|
||||||
|
local_allow_requested(bool(getattr(args, "allow", False)))
|
||||||
|
binding = getattr(args, "stage_binding", None)
|
||||||
|
entry = get_entry(cfg.catalog_dir, args.catalog_id)
|
||||||
|
foreign = getattr(args, "foreign_stage_credential_file", None) or ""
|
||||||
|
foreign_value = ""
|
||||||
|
if foreign:
|
||||||
|
foreign_value = "present"
|
||||||
|
plans = run_service(
|
||||||
|
binding,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entry": entry,
|
||||||
|
"operation": args.operation,
|
||||||
|
"foreign_stage_credential": foreign_value,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for plan in plans:
|
||||||
|
print(plan.render())
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def cmd_apply(cfg: Config, args) -> int:
|
def cmd_apply(cfg: Config, args) -> int:
|
||||||
|
from secrets_engine.service import local_allow_requested
|
||||||
|
|
||||||
|
local_allow_requested(bool(getattr(args, "allow", False)))
|
||||||
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
entry = _resolve_lane_and_decision(cfg, args.ref, args.stage)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
decision = None
|
decision = None
|
||||||
|
|
@ -1064,6 +1105,34 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
dinsp.add_argument("ref")
|
dinsp.add_argument("ref")
|
||||||
dinsp.set_defaults(func=cmd_decision_inspect)
|
dinsp.set_defaults(func=cmd_decision_inspect)
|
||||||
|
|
||||||
|
inst = sub.add_parser(
|
||||||
|
"instance",
|
||||||
|
help="print the one stage bound at startup; never prints the credential",
|
||||||
|
)
|
||||||
|
inst.set_defaults(func=cmd_instance)
|
||||||
|
|
||||||
|
srv = sub.add_parser(
|
||||||
|
"serve",
|
||||||
|
help="run the lifecycle library for the one stage injected at startup",
|
||||||
|
)
|
||||||
|
srv.add_argument("catalog_id")
|
||||||
|
srv.add_argument(
|
||||||
|
"--operation",
|
||||||
|
required=True,
|
||||||
|
choices=("suspend", "deactivate", "destroy"),
|
||||||
|
)
|
||||||
|
srv.add_argument(
|
||||||
|
"--allow",
|
||||||
|
action="store_true",
|
||||||
|
help="ignored; a local flag does not authorize this process",
|
||||||
|
)
|
||||||
|
srv.add_argument(
|
||||||
|
"--foreign-stage-credential-file",
|
||||||
|
default="",
|
||||||
|
help=argparse.SUPPRESS,
|
||||||
|
)
|
||||||
|
srv.set_defaults(func=cmd_serve)
|
||||||
|
|
||||||
pl = sub.add_parser("plan", help="render a guarded plan (no mutation)")
|
pl = sub.add_parser("plan", help="render a guarded plan (no mutation)")
|
||||||
pl.add_argument("ref", help="decision/ccr id or catalog id")
|
pl.add_argument("ref", help="decision/ccr id or catalog id")
|
||||||
pl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
pl.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||||
|
|
@ -1073,6 +1142,11 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
ap.add_argument("ref")
|
ap.add_argument("ref")
|
||||||
ap.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
ap.add_argument("--stage", required=True, choices=("build", "test", "prod"))
|
||||||
ap.add_argument("--dry-run", action="store_true")
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument(
|
||||||
|
"--allow",
|
||||||
|
action="store_true",
|
||||||
|
help="ignored; a local flag does not authorize apply",
|
||||||
|
)
|
||||||
add_token_arg(ap)
|
add_token_arg(ap)
|
||||||
ap.set_defaults(func=cmd_apply)
|
ap.set_defaults(func=cmd_apply)
|
||||||
|
|
||||||
|
|
@ -1273,8 +1347,14 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
argv = list(sys.argv[1:] if argv is None else argv)
|
argv = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
try:
|
||||||
|
binding = load_stage_binding()
|
||||||
|
except StartupError as e:
|
||||||
|
print(f"error: {e}", file=sys.stderr)
|
||||||
|
return e.exit_code
|
||||||
parser = build_parser()
|
parser = build_parser()
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
args.stage_binding = binding
|
||||||
# `exec` REMAINDER includes a leading '--'; strip it.
|
# `exec` REMAINDER includes a leading '--'; strip it.
|
||||||
if getattr(args, "command", None) and args.command and args.command[0] == "--":
|
if getattr(args, "command", None) and args.command and args.command[0] == "--":
|
||||||
args.command = args.command[1:]
|
args.command = args.command[1:]
|
||||||
|
|
|
||||||
71
src/secrets_engine/service.py
Normal file
71
src/secrets_engine/service.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""Kept-running manifestation of the lifecycle library.
|
||||||
|
|
||||||
|
This is the same library the CLI uses, in a process that stays up for one
|
||||||
|
injected stage. It is not a second implementation and not a Deployment.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from collections.abc import Iterable, Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from secrets_engine import lifecycle as lifecycle_lib
|
||||||
|
from secrets_engine.catalog import CatalogEntry
|
||||||
|
from secrets_engine.lifecycle import LifecyclePlan
|
||||||
|
from secrets_engine.stage_binding import StageBinding, StartupError
|
||||||
|
|
||||||
|
LOCAL_ALLOW_ENV = "SECRETS_ENGINE_LOCAL_ALLOW"
|
||||||
|
|
||||||
|
|
||||||
|
def local_allow_requested(flag: bool = False) -> bool:
|
||||||
|
"""True when a caller asked to be allowed. The answer is not authority."""
|
||||||
|
env = os.environ.get(LOCAL_ALLOW_ENV, "").strip() in {"1", "true", "yes"}
|
||||||
|
return bool(flag) or env
|
||||||
|
|
||||||
|
|
||||||
|
def run_service(
|
||||||
|
binding: StageBinding | None,
|
||||||
|
jobs: Iterable[Mapping[str, Any]],
|
||||||
|
) -> list[LifecyclePlan]:
|
||||||
|
"""Run lifecycle jobs for one stage until the job iterable ends.
|
||||||
|
|
||||||
|
A job may carry ``foreign_stage_credential`` only as a refusal test. A
|
||||||
|
present value refuses the job. The binding credential is never rendered.
|
||||||
|
"""
|
||||||
|
if binding is None or not binding.credential or not binding.stage:
|
||||||
|
raise StartupError("startup refused: no stage credential is present")
|
||||||
|
plans: list[LifecyclePlan] = []
|
||||||
|
for job in jobs:
|
||||||
|
foreign = str(job.get("foreign_stage_credential") or "")
|
||||||
|
if foreign:
|
||||||
|
raise StartupError(
|
||||||
|
"startup refused: another stage credential is present"
|
||||||
|
)
|
||||||
|
entry = job.get("entry")
|
||||||
|
if not isinstance(entry, CatalogEntry):
|
||||||
|
raise StartupError("service job is missing a catalog entry")
|
||||||
|
operation = str(job.get("operation") or "")
|
||||||
|
plans.append(lifecycle_lib.build_lifecycle_plan(entry, operation))
|
||||||
|
return plans
|
||||||
|
|
||||||
|
|
||||||
|
def invoke_protected(
|
||||||
|
cfg: Any,
|
||||||
|
entry: CatalogEntry,
|
||||||
|
action: str,
|
||||||
|
*,
|
||||||
|
allow: bool = False,
|
||||||
|
auth_args: Any = None,
|
||||||
|
) -> Any:
|
||||||
|
"""Gate a protected call the way the CLI does.
|
||||||
|
|
||||||
|
``allow`` and ``SECRETS_ENGINE_LOCAL_ALLOW`` are recorded as not authority
|
||||||
|
and then discarded. The call still requires a decision record or the
|
||||||
|
published unreachable-engine stance.
|
||||||
|
"""
|
||||||
|
local_allow_requested(allow)
|
||||||
|
from secrets_engine.cli import _require_lane_approval
|
||||||
|
|
||||||
|
return _require_lane_approval(
|
||||||
|
cfg, entry, action, None, auth_args=auth_args
|
||||||
|
)
|
||||||
60
src/secrets_engine/stage_binding.py
Normal file
60
src/secrets_engine/stage_binding.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""One stage credential, injected at process start.
|
||||||
|
|
||||||
|
The process may hold the credential for exactly one of build, test, or
|
||||||
|
production. The value stays in memory. Startup errors name the condition and
|
||||||
|
never include the credential.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from secrets_engine.errors import SecretsEngineError
|
||||||
|
|
||||||
|
STAGE_TOKEN_FILES = {
|
||||||
|
"build": "SECRETS_ENGINE_STAGE_TOKEN_FILE_BUILD",
|
||||||
|
"test": "SECRETS_ENGINE_STAGE_TOKEN_FILE_TEST",
|
||||||
|
"production": "SECRETS_ENGINE_STAGE_TOKEN_FILE_PRODUCTION",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StartupError(SecretsEngineError):
|
||||||
|
"""Process start refused before a command runs."""
|
||||||
|
|
||||||
|
exit_code = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StageBinding:
|
||||||
|
stage: str
|
||||||
|
credential: str
|
||||||
|
|
||||||
|
def public(self) -> str:
|
||||||
|
return f"stage={self.stage}"
|
||||||
|
|
||||||
|
|
||||||
|
def load_stage_binding(environ: dict[str, str] | None = None) -> StageBinding | None:
|
||||||
|
"""Read at most one stage credential file. None means the caller set none."""
|
||||||
|
env = os.environ if environ is None else environ
|
||||||
|
present: list[tuple[str, str]] = []
|
||||||
|
for stage, key in STAGE_TOKEN_FILES.items():
|
||||||
|
raw = env.get(key, "").strip()
|
||||||
|
if raw:
|
||||||
|
present.append((stage, raw))
|
||||||
|
if len(present) > 1:
|
||||||
|
raise StartupError(
|
||||||
|
"startup refused: more than one stage credential is present"
|
||||||
|
)
|
||||||
|
if not present:
|
||||||
|
return None
|
||||||
|
stage, path_text = present[0]
|
||||||
|
try:
|
||||||
|
credential = Path(path_text).read_text(encoding="utf-8").strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise StartupError(
|
||||||
|
"startup refused: stage credential file is unreadable"
|
||||||
|
) from exc
|
||||||
|
if not credential:
|
||||||
|
raise StartupError("startup refused: stage credential file is empty")
|
||||||
|
return StageBinding(stage=stage, credential=credential)
|
||||||
247
tests/test_wp0016_manifestations.py
Normal file
247
tests/test_wp0016_manifestations.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""SECRETS-WP-0016: plan stays off the store client; apply still calls it.
|
||||||
|
|
||||||
|
Later cases cover one-stage process binding, the in-process service, and the
|
||||||
|
refusal of a local allow flag. Fixture credentials stay out of captured output.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from secrets_engine import cli, lifecycle
|
||||||
|
from secrets_engine.apply import apply_plan
|
||||||
|
from secrets_engine.catalog import validate_entry
|
||||||
|
from secrets_engine.config import Config, repo_root
|
||||||
|
from secrets_engine.errors import DecisionError
|
||||||
|
from secrets_engine.openbao import OpenBaoClient
|
||||||
|
from secrets_engine.plan import build_plan
|
||||||
|
from secrets_engine.service import invoke_protected, run_service
|
||||||
|
from secrets_engine.stage_binding import STAGE_TOKEN_FILES, StageBinding, StartupError
|
||||||
|
from tests.test_catalog import VALID
|
||||||
|
|
||||||
|
SENTINEL = "fixture-stage-credential-wp0016"
|
||||||
|
STAGE_ENV = tuple(STAGE_TOKEN_FILES.values())
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingApplyClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
def kv_mount_exists(self, mount):
|
||||||
|
self.calls.append("kv_mount_exists")
|
||||||
|
assert mount
|
||||||
|
return False
|
||||||
|
|
||||||
|
def ensure_kv_mount(self, mount):
|
||||||
|
self.calls.append("ensure_kv_mount")
|
||||||
|
assert mount
|
||||||
|
|
||||||
|
def read_policy(self, name):
|
||||||
|
self.calls.append("read_policy")
|
||||||
|
assert name
|
||||||
|
return None
|
||||||
|
|
||||||
|
def write_policy(self, name, hcl):
|
||||||
|
self.calls.append("write_policy")
|
||||||
|
assert name and hcl
|
||||||
|
|
||||||
|
def ensure_approle_enabled(self):
|
||||||
|
self.calls.append("ensure_approle_enabled")
|
||||||
|
|
||||||
|
def write_approle(self, role_name, policies, ttl="30m", **kwargs):
|
||||||
|
self.calls.append("write_approle")
|
||||||
|
assert role_name and policies and ttl
|
||||||
|
del kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_stage_env(monkeypatch):
|
||||||
|
for key in STAGE_ENV:
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
monkeypatch.delenv("SECRETS_ENGINE_LOCAL_ALLOW", raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _isolate_authority(monkeypatch, tmp_path: Path):
|
||||||
|
"""No serving PDP, no published stance file, no stage credential."""
|
||||||
|
_clear_stage_env(monkeypatch)
|
||||||
|
monkeypatch.setenv("SECRETS_ENGINE_PEP_STANCE", str(tmp_path / "missing-stance.yaml"))
|
||||||
|
for key in (
|
||||||
|
"SECRETS_ENGINE_APPROVAL_URL",
|
||||||
|
"SECRETS_ENGINE_APPROVAL_TOKEN_FILE",
|
||||||
|
"SECRETS_ENGINE_PDP_URL",
|
||||||
|
"SECRETS_ENGINE_PDP_TOKEN_FILE",
|
||||||
|
"SECRETS_ENGINE_PDP_TRUST_FILE",
|
||||||
|
"SECRETS_ENGINE_PDP_VERIFIER",
|
||||||
|
"SECRETS_ENGINE_OPENBAO_JWT_LOGIN",
|
||||||
|
"BAO_TOKEN",
|
||||||
|
"VAULT_TOKEN",
|
||||||
|
):
|
||||||
|
monkeypatch.delenv(key, raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plan_path_constructs_no_openbao_client(monkeypatch):
|
||||||
|
constructed: list[str] = []
|
||||||
|
|
||||||
|
def _refuse_init(self, *args, **kwargs):
|
||||||
|
constructed.append("init")
|
||||||
|
raise AssertionError("plan path constructed an OpenBao client")
|
||||||
|
|
||||||
|
def _refuse_resolve(cls, *args, **kwargs):
|
||||||
|
constructed.append("resolve")
|
||||||
|
raise AssertionError("plan path resolved an OpenBao client")
|
||||||
|
|
||||||
|
monkeypatch.setattr(OpenBaoClient, "__init__", _refuse_init)
|
||||||
|
monkeypatch.setattr(OpenBaoClient, "resolve", _refuse_resolve)
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
plan = build_plan(entry, "test", decision_id="d1")
|
||||||
|
assert plan.catalog_id == entry.id
|
||||||
|
assert plan.actions
|
||||||
|
assert constructed == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_path_issues_openbao_calls():
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
plan = build_plan(entry, "test", decision_id="d1")
|
||||||
|
client = RecordingApplyClient()
|
||||||
|
result = apply_plan(client, entry, plan)
|
||||||
|
for name in ("ensure_kv_mount", "write_policy", "write_approle"):
|
||||||
|
assert name in client.calls
|
||||||
|
assert any(name.startswith("kv-mount") for name in result.applied)
|
||||||
|
assert any(name.startswith("policy") for name in result.applied)
|
||||||
|
assert any(name.startswith("approle") for name in result.applied)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_stage_file(tmp_path: Path, name: str) -> Path:
|
||||||
|
path = tmp_path / name
|
||||||
|
path.write_text(SENTINEL + "\n", encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_sentinel_absent(captured):
|
||||||
|
assert SENTINEL not in captured.out
|
||||||
|
assert SENTINEL not in captured.err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stage", ("build", "test", "production"))
|
||||||
|
def test_cli_binds_one_stage_and_hides_the_credential(stage, tmp_path, monkeypatch, capsys):
|
||||||
|
_clear_stage_env(monkeypatch)
|
||||||
|
path = _write_stage_file(tmp_path, stage)
|
||||||
|
monkeypatch.setenv(STAGE_TOKEN_FILES[stage], str(path))
|
||||||
|
for _ in range(2):
|
||||||
|
capsys.readouterr()
|
||||||
|
code = cli.main(["instance"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code == 0
|
||||||
|
assert captured.out.strip() == f"stage={stage}"
|
||||||
|
_assert_sentinel_absent(captured)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_refuses_two_stage_credentials(tmp_path, monkeypatch, capsys):
|
||||||
|
_clear_stage_env(monkeypatch)
|
||||||
|
monkeypatch.setenv(STAGE_TOKEN_FILES["build"], str(_write_stage_file(tmp_path, "build")))
|
||||||
|
monkeypatch.setenv(STAGE_TOKEN_FILES["test"], str(_write_stage_file(tmp_path, "test")))
|
||||||
|
for _ in range(2):
|
||||||
|
capsys.readouterr()
|
||||||
|
code = cli.main(["instance"])
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code != 0
|
||||||
|
assert "more than one stage credential is present" in captured.err
|
||||||
|
_assert_sentinel_absent(captured)
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_lifecycle_uses_the_cli_library_function(monkeypatch):
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
binding = StageBinding(stage="test", credential=SENTINEL)
|
||||||
|
seen: list[tuple[str, str]] = []
|
||||||
|
real = lifecycle.build_lifecycle_plan
|
||||||
|
|
||||||
|
def spy(entry_arg, operation):
|
||||||
|
seen.append((entry_arg.id, operation))
|
||||||
|
return real(entry_arg, operation)
|
||||||
|
|
||||||
|
monkeypatch.setattr(lifecycle, "build_lifecycle_plan", spy)
|
||||||
|
plans = run_service(binding, [{"entry": entry, "operation": "suspend"}])
|
||||||
|
direct = real(entry, "suspend")
|
||||||
|
assert seen == [(entry.id, "suspend")]
|
||||||
|
assert plans[0].operation == direct.operation
|
||||||
|
assert plans[0].catalog_id == direct.catalog_id
|
||||||
|
assert [action.kind for action in plans[0].actions] == [
|
||||||
|
action.kind for action in direct.actions
|
||||||
|
]
|
||||||
|
rendered = plans[0].render()
|
||||||
|
assert SENTINEL not in rendered
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_refuses_a_second_stage_credential():
|
||||||
|
entry = validate_entry(copy.deepcopy(VALID))
|
||||||
|
binding = StageBinding(stage="build", credential=SENTINEL)
|
||||||
|
with pytest.raises(StartupError, match="another stage credential"):
|
||||||
|
run_service(
|
||||||
|
binding,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"entry": entry,
|
||||||
|
"operation": "suspend",
|
||||||
|
"foreign_stage_credential": "present",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_deployment_manifest_was_added():
|
||||||
|
root = repo_root()
|
||||||
|
for path in root.rglob("*"):
|
||||||
|
if ".git" in path.parts or path.suffix not in {".yaml", ".yml"}:
|
||||||
|
continue
|
||||||
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
assert "kind: Deployment" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_allow_does_not_authorize_protected_apply(tmp_path, monkeypatch, capsys):
|
||||||
|
_isolate_authority(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("SECRETS_ENGINE_LOCAL_ALLOW", "1")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
OpenBaoClient,
|
||||||
|
"resolve",
|
||||||
|
lambda *_a, **_k: pytest.fail("protected apply opened a backend"),
|
||||||
|
)
|
||||||
|
code = cli.main(
|
||||||
|
[
|
||||||
|
"apply",
|
||||||
|
"whynot-design-npm-publish",
|
||||||
|
"--stage",
|
||||||
|
"prod",
|
||||||
|
"--auth",
|
||||||
|
"env",
|
||||||
|
"--allow",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert code != 0
|
||||||
|
assert "applied:" not in captured.out
|
||||||
|
assert "unable to load PEP stance" in captured.err or "DecisionError" in captured.err or "error:" in captured.err
|
||||||
|
_assert_sentinel_absent(captured)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_allow_does_not_authorize_protected_service_call(tmp_path, monkeypatch):
|
||||||
|
_isolate_authority(monkeypatch, tmp_path)
|
||||||
|
monkeypatch.setenv("SECRETS_ENGINE_LOCAL_ALLOW", "1")
|
||||||
|
entry = cli.get_entry(repo_root() / "catalog", "whynot-design-npm-publish")
|
||||||
|
with pytest.raises(DecisionError):
|
||||||
|
invoke_protected(Config.load(), entry, "apply", allow=True, auth_args=_Auth())
|
||||||
|
|
||||||
|
|
||||||
|
class _Auth:
|
||||||
|
auth = "env"
|
||||||
|
bootstrap_token_file = None
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_claim_names_access_engine_and_stores_no_credential():
|
||||||
|
path = repo_root() / "docs" / "engine-use-claim.md"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
assert "access-engine" in text
|
||||||
|
assert SENTINEL not in text
|
||||||
|
lowered = text.lower()
|
||||||
|
assert "token=" not in lowered
|
||||||
|
assert "bao_token" not in lowered
|
||||||
|
|
@ -4,7 +4,7 @@ type: workplan
|
||||||
title: "Manifest the lifecycle engine as one library, a CLI, and a single-stage service"
|
title: "Manifest the lifecycle engine as one library, a CLI, and a single-stage service"
|
||||||
domain: infotech
|
domain: infotech
|
||||||
repo: secrets-engine
|
repo: secrets-engine
|
||||||
status: ready
|
status: finished
|
||||||
owner: secrets-engine
|
owner: secrets-engine
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
created: "2026-10-22"
|
created: "2026-10-22"
|
||||||
|
|
@ -24,12 +24,13 @@ Implements the engine shape in `INTENT.md` after gate-house ADR-015
|
||||||
(accepted 2026-10-22) and `ArchitectureBlueprint.md` §2.2.
|
(accepted 2026-10-22) and `ArchitectureBlueprint.md` §2.2.
|
||||||
|
|
||||||
Reviewed against the tree on 2026-10-22. `secrets_engine.plan.build_plan`
|
Reviewed against the tree on 2026-10-22. `secrets_engine.plan.build_plan`
|
||||||
already renders a plan without an OpenBao client. `secrets_engine.cli` is the
|
renders a plan without an OpenBao client. `secrets_engine.cli` is the process,
|
||||||
process. `SCOPE.md` records that service delivery is absent, and that a
|
bound to one stage credential at start. `serve` keeps that same library
|
||||||
service API or daemon is not implemented. `service-jwt` can log in and cannot
|
running for the bound stage. `SCOPE.md` records the library, the one-stage
|
||||||
yet carry engine actions. Accepted security-layer-model v0.8 still governs
|
CLI, and that in-process manifestation. Exec-time delivery remains the default
|
||||||
PEP behavior. Proposed v0.9 §3.3 carries the same engine wording and does not
|
for a one-task caller. `service-jwt` can log in and cannot yet carry engine
|
||||||
yet govern.
|
actions. Accepted security-layer-model v0.8 still governs PEP behavior.
|
||||||
|
Proposed v0.9 §3.3 carries the same engine wording and does not yet govern.
|
||||||
|
|
||||||
This workplan does not add a release-unit declaration, does not deploy a
|
This workplan does not add a release-unit declaration, does not deploy a
|
||||||
workload, and does not make this engine the only client of OpenBao or a
|
workload, and does not make this engine the only client of OpenBao or a
|
||||||
|
|
@ -39,7 +40,7 @@ decider of who may use it.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0016-T01
|
id: SECRETS-WP-0016-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "4319d9c6-f7e1-500c-9011-a58c216d555d"
|
state_hub_task_id: "4319d9c6-f7e1-500c-9011-a58c216d555d"
|
||||||
```
|
```
|
||||||
|
|
@ -56,7 +57,7 @@ path still performs the OpenBao calls.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0016-T02
|
id: SECRETS-WP-0016-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
depends_on: [SECRETS-WP-0016-T01]
|
depends_on: [SECRETS-WP-0016-T01]
|
||||||
state_hub_task_id: "f5d5e9ba-ba56-56cc-8920-1417d70beb1d"
|
state_hub_task_id: "f5d5e9ba-ba56-56cc-8920-1417d70beb1d"
|
||||||
|
|
@ -75,7 +76,7 @@ stderr, and shows startup failure when two stage credentials are present.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0016-T03
|
id: SECRETS-WP-0016-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [SECRETS-WP-0016-T02]
|
depends_on: [SECRETS-WP-0016-T02]
|
||||||
state_hub_task_id: "c37280f3-e9d0-5cdd-ba15-a95ec5f9aa0c"
|
state_hub_task_id: "c37280f3-e9d0-5cdd-ba15-a95ec5f9aa0c"
|
||||||
|
|
@ -92,7 +93,7 @@ lifecycle call goes through the shipped library functions used by the CLI.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0016-T04
|
id: SECRETS-WP-0016-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
depends_on: [SECRETS-WP-0016-T03]
|
depends_on: [SECRETS-WP-0016-T03]
|
||||||
state_hub_task_id: "89b71b5b-a2ce-57d6-b16b-0f94d76e0104"
|
state_hub_task_id: "89b71b5b-a2ce-57d6-b16b-0f94d76e0104"
|
||||||
|
|
@ -111,7 +112,7 @@ and the claim shape is written in `docs/` without storing a credential.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: SECRETS-WP-0016-T05
|
id: SECRETS-WP-0016-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
depends_on: [SECRETS-WP-0016-T03, SECRETS-WP-0016-T04]
|
depends_on: [SECRETS-WP-0016-T03, SECRETS-WP-0016-T04]
|
||||||
state_hub_task_id: "6b013ea1-4a04-57e8-bab2-acb196303e0a"
|
state_hub_task_id: "6b013ea1-4a04-57e8-bab2-acb196303e0a"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue