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
|
||||
|
||||
secrets-engine is the Lifecycle engine for cataloged credential work: a
|
||||
decision-gated Python CLI that validates non-secret lane metadata and
|
||||
orchestrates narrowly scoped OpenBao policy, AppRole, provisioning,
|
||||
verification, delivery, routing, handoff, and revocation actions.
|
||||
secrets-engine is the Lifecycle engine for cataloged credential work. Its
|
||||
library validates non-secret lane metadata and orchestrates narrowly scoped
|
||||
OpenBao policy, AppRole, provisioning, verification, delivery, routing,
|
||||
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
|
||||
point, not an identity provider, and not a general secrets API.
|
||||
It is a deterministic processor over OpenBao, not a vault, not a policy
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
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
|
||||
|
||||
```text
|
||||
secrets-engine instance
|
||||
secrets-engine serve <catalog-id> --operation suspend|deactivate|destroy
|
||||
secrets-engine catalog list|show
|
||||
secrets-engine decision inspect
|
||||
secrets-engine plan
|
||||
|
|
@ -262,7 +282,8 @@ metadata. `secrets-engine wrap` implements response-wrapped operator handoff.
|
|||
|
||||
## 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
|
||||
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
|
||||
|
|
@ -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
|
||||
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;
|
||||
`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]
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
| 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-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-23-T01 | done | — | workplans/ADHOC-2026-08-23.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-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-0016-T01 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||
| task | SECRETS-WP-0016-T02 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||
| task | SECRETS-WP-0016-T03 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||
| task | SECRETS-WP-0016-T04 | todo | — | workplans/SECRETS-WP-0016-engine-manifestations.md |
|
||||
| task | SECRETS-WP-0016-T05 | 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 | done | — | 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 | done | — | 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-0002 | 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.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.provision import provision_from_file, provision_generated
|
||||
from secrets_engine.routing import route_lane
|
||||
|
|
@ -281,7 +282,47 @@ def cmd_plan(cfg: Config, args) -> int:
|
|||
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:
|
||||
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)
|
||||
if args.dry_run:
|
||||
decision = None
|
||||
|
|
@ -1064,6 +1105,34 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
dinsp.add_argument("ref")
|
||||
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.add_argument("ref", help="decision/ccr id or catalog id")
|
||||
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("--stage", required=True, choices=("build", "test", "prod"))
|
||||
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)
|
||||
ap.set_defaults(func=cmd_apply)
|
||||
|
||||
|
|
@ -1273,8 +1347,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
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()
|
||||
args = parser.parse_args(argv)
|
||||
args.stage_binding = binding
|
||||
# `exec` REMAINDER includes a leading '--'; strip it.
|
||||
if getattr(args, "command", None) and args.command and args.command[0] == "--":
|
||||
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"
|
||||
domain: infotech
|
||||
repo: secrets-engine
|
||||
status: ready
|
||||
status: finished
|
||||
owner: secrets-engine
|
||||
topic_slug: custodian
|
||||
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.
|
||||
|
||||
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
|
||||
process. `SCOPE.md` records that service delivery is absent, and that a
|
||||
service API or daemon is not implemented. `service-jwt` can log in and cannot
|
||||
yet carry engine 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.
|
||||
renders a plan without an OpenBao client. `secrets_engine.cli` is the process,
|
||||
bound to one stage credential at start. `serve` keeps that same library
|
||||
running for the bound stage. `SCOPE.md` records the library, the one-stage
|
||||
CLI, and that in-process manifestation. Exec-time delivery remains the default
|
||||
for a one-task caller. `service-jwt` can log in and cannot yet carry engine
|
||||
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
|
||||
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
|
||||
id: SECRETS-WP-0016-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "4319d9c6-f7e1-500c-9011-a58c216d555d"
|
||||
```
|
||||
|
|
@ -56,7 +57,7 @@ path still performs the OpenBao calls.
|
|||
|
||||
```task
|
||||
id: SECRETS-WP-0016-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
depends_on: [SECRETS-WP-0016-T01]
|
||||
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
|
||||
id: SECRETS-WP-0016-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [SECRETS-WP-0016-T02]
|
||||
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
|
||||
id: SECRETS-WP-0016-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
depends_on: [SECRETS-WP-0016-T03]
|
||||
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
|
||||
id: SECRETS-WP-0016-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: low
|
||||
depends_on: [SECRETS-WP-0016-T03, SECRETS-WP-0016-T04]
|
||||
state_hub_task_id: "6b013ea1-4a04-57e8-bab2-acb196303e0a"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue