auth51-governance 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth51_governance-0.1.0/.github/workflows/publish.yml +31 -0
- auth51_governance-0.1.0/.gitignore +9 -0
- auth51_governance-0.1.0/PKG-INFO +81 -0
- auth51_governance-0.1.0/README.md +65 -0
- auth51_governance-0.1.0/pyproject.toml +30 -0
- auth51_governance-0.1.0/src/auth51_governance/__init__.py +55 -0
- auth51_governance-0.1.0/src/auth51_governance/model.py +58 -0
- auth51_governance-0.1.0/src/auth51_governance/observe.py +33 -0
- auth51_governance-0.1.0/src/auth51_governance/policy.py +292 -0
- auth51_governance-0.1.0/src/auth51_governance/synthesize.py +190 -0
- auth51_governance-0.1.0/src/auth51_governance/validate.py +63 -0
- auth51_governance-0.1.0/tests/test_policy.py +100 -0
- auth51_governance-0.1.0/tests/test_synthesize.py +129 -0
- auth51_governance-0.1.0/tests/test_validate.py +75 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
# Publishes on a GitHub Release. Uses PyPI Trusted Publishing (OIDC) — no stored
|
|
4
|
+
# API token. Configure the trusted publisher on PyPI first: project auth51-governance
|
|
5
|
+
# → publisher = this repo, workflow "publish.yml", environment "pypi".
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
release:
|
|
9
|
+
types: [published]
|
|
10
|
+
workflow_dispatch: {}
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
pypi:
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
environment: pypi
|
|
19
|
+
permissions:
|
|
20
|
+
# Job-level permissions REPLACE the workflow-level block (not merged), so
|
|
21
|
+
# both must be listed here or checkout 404s on a private repo.
|
|
22
|
+
contents: read # let actions/checkout read the (private) repo
|
|
23
|
+
id-token: write # mint the OIDC token PyPI verifies (no stored secret)
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v4
|
|
26
|
+
- uses: actions/setup-python@v5
|
|
27
|
+
with:
|
|
28
|
+
python-version: "3.12"
|
|
29
|
+
- run: python -m pip install --upgrade build
|
|
30
|
+
- run: python -m build
|
|
31
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: auth51-governance
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The auth51 governance engine — classify action consequence into tiers, synthesize guarded workflows from observed runs, and validate a proposed workflow against the tier floor at registration.
|
|
5
|
+
Project-URL: Homepage, https://auth51.com
|
|
6
|
+
Project-URL: Repository, https://github.com/unforge-io/auth51-governance
|
|
7
|
+
Author-email: unforge <dev-admin@unforge.io>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Keywords: agentic-authorization,ai-agents,auth51,governance,workflow
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# auth51-governance
|
|
18
|
+
|
|
19
|
+
The governance-time engine for auth51. It does three things, all pure and stdlib-only:
|
|
20
|
+
|
|
21
|
+
1. **Classify** an action's consequence into an ordered tier
|
|
22
|
+
(`READ < WRITE < SENSITIVE < FINANCIAL < DESTRUCTIVE`).
|
|
23
|
+
2. **Synthesize** a guarded workflow from sampled run observations plus the roster's
|
|
24
|
+
capability catalog. Guard *strength* follows the tier, not a single boolean.
|
|
25
|
+
3. **Validate** a proposed workflow against the tier floor, so the authority can reject
|
|
26
|
+
a workflow that frees a dangerous capability regardless of who proposed it.
|
|
27
|
+
|
|
28
|
+
## Why it's a separate library
|
|
29
|
+
|
|
30
|
+
Deriving and registering workflows is a **governance-time** act, not a runtime one —
|
|
31
|
+
even though the learning signal (observations) comes from runs. Any client can produce
|
|
32
|
+
observations: the reference workforce, a custom app on the auth51 client, or the
|
|
33
|
+
auth51d gateway. They all feed the same engine here, and the authority stays the single
|
|
34
|
+
source of truth for guard strength by calling `validate_workflow` at registration.
|
|
35
|
+
|
|
36
|
+
Kept dependency-light and portable, exactly like `auth51-checksum`, so both the
|
|
37
|
+
authority and clients can depend on it without pulling a framework.
|
|
38
|
+
|
|
39
|
+
## The guard ladder
|
|
40
|
+
|
|
41
|
+
| | observed (≥ `min_support` runs) | unobserved / under-supported |
|
|
42
|
+
|-------------|--------------------------------------------|------------------------------|
|
|
43
|
+
| READ | free | (no step) |
|
|
44
|
+
| WRITE | free | (no step) |
|
|
45
|
+
| SENSITIVE | ordered (must follow legit predecessors) | approval gate |
|
|
46
|
+
| FINANCIAL | ordered (value-bound in a later phase) | approval gate |
|
|
47
|
+
| DESTRUCTIVE | approval gate (never downgraded) | approval gate |
|
|
48
|
+
|
|
49
|
+
Two properties over the old binary model: a real ladder (an identity edit ≠ a money
|
|
50
|
+
movement), and high tiers never auto-free (a DESTRUCTIVE op seen once still needs a
|
|
51
|
+
human; a FINANCIAL op is never free).
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from auth51_governance import (
|
|
57
|
+
Capability, obs_from_spans, synthesize, validate_workflow, ConsequenceTier,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# what the roster CAN do (classified from spec risk/tags, else the name)
|
|
61
|
+
caps = [
|
|
62
|
+
Capability.classify("refunder", "RetrieveCustomer"),
|
|
63
|
+
Capability.classify("refunder", "CreatePayout", risk="financial"),
|
|
64
|
+
Capability.classify("admin", "DropDatabase"),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
# what runs DID (from each run's spans)
|
|
68
|
+
runs = [obs_from_spans(spans) for spans in sampled_runs]
|
|
69
|
+
|
|
70
|
+
wf = synthesize("refund-flow", runs, caps) # a guarded WorkflowDefinition dict
|
|
71
|
+
|
|
72
|
+
# at the authority, before registering:
|
|
73
|
+
violations = validate_workflow(wf, tier_of=lambda a, act: my_registry_tier(a, act))
|
|
74
|
+
assert not violations
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Compiles down to the authority's step primitives
|
|
78
|
+
|
|
79
|
+
`synthesize` emits the same `WorkflowStep` shape the authority already enforces:
|
|
80
|
+
`dependencies` (ordering), `scopes` (subset), and an approval-gate step. Each step also
|
|
81
|
+
carries a `tier` annotation the authority stores and the value-binding phase will use.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# auth51-governance
|
|
2
|
+
|
|
3
|
+
The governance-time engine for auth51. It does three things, all pure and stdlib-only:
|
|
4
|
+
|
|
5
|
+
1. **Classify** an action's consequence into an ordered tier
|
|
6
|
+
(`READ < WRITE < SENSITIVE < FINANCIAL < DESTRUCTIVE`).
|
|
7
|
+
2. **Synthesize** a guarded workflow from sampled run observations plus the roster's
|
|
8
|
+
capability catalog. Guard *strength* follows the tier, not a single boolean.
|
|
9
|
+
3. **Validate** a proposed workflow against the tier floor, so the authority can reject
|
|
10
|
+
a workflow that frees a dangerous capability regardless of who proposed it.
|
|
11
|
+
|
|
12
|
+
## Why it's a separate library
|
|
13
|
+
|
|
14
|
+
Deriving and registering workflows is a **governance-time** act, not a runtime one —
|
|
15
|
+
even though the learning signal (observations) comes from runs. Any client can produce
|
|
16
|
+
observations: the reference workforce, a custom app on the auth51 client, or the
|
|
17
|
+
auth51d gateway. They all feed the same engine here, and the authority stays the single
|
|
18
|
+
source of truth for guard strength by calling `validate_workflow` at registration.
|
|
19
|
+
|
|
20
|
+
Kept dependency-light and portable, exactly like `auth51-checksum`, so both the
|
|
21
|
+
authority and clients can depend on it without pulling a framework.
|
|
22
|
+
|
|
23
|
+
## The guard ladder
|
|
24
|
+
|
|
25
|
+
| | observed (≥ `min_support` runs) | unobserved / under-supported |
|
|
26
|
+
|-------------|--------------------------------------------|------------------------------|
|
|
27
|
+
| READ | free | (no step) |
|
|
28
|
+
| WRITE | free | (no step) |
|
|
29
|
+
| SENSITIVE | ordered (must follow legit predecessors) | approval gate |
|
|
30
|
+
| FINANCIAL | ordered (value-bound in a later phase) | approval gate |
|
|
31
|
+
| DESTRUCTIVE | approval gate (never downgraded) | approval gate |
|
|
32
|
+
|
|
33
|
+
Two properties over the old binary model: a real ladder (an identity edit ≠ a money
|
|
34
|
+
movement), and high tiers never auto-free (a DESTRUCTIVE op seen once still needs a
|
|
35
|
+
human; a FINANCIAL op is never free).
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from auth51_governance import (
|
|
41
|
+
Capability, obs_from_spans, synthesize, validate_workflow, ConsequenceTier,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# what the roster CAN do (classified from spec risk/tags, else the name)
|
|
45
|
+
caps = [
|
|
46
|
+
Capability.classify("refunder", "RetrieveCustomer"),
|
|
47
|
+
Capability.classify("refunder", "CreatePayout", risk="financial"),
|
|
48
|
+
Capability.classify("admin", "DropDatabase"),
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# what runs DID (from each run's spans)
|
|
52
|
+
runs = [obs_from_spans(spans) for spans in sampled_runs]
|
|
53
|
+
|
|
54
|
+
wf = synthesize("refund-flow", runs, caps) # a guarded WorkflowDefinition dict
|
|
55
|
+
|
|
56
|
+
# at the authority, before registering:
|
|
57
|
+
violations = validate_workflow(wf, tier_of=lambda a, act: my_registry_tier(a, act))
|
|
58
|
+
assert not violations
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Compiles down to the authority's step primitives
|
|
62
|
+
|
|
63
|
+
`synthesize` emits the same `WorkflowStep` shape the authority already enforces:
|
|
64
|
+
`dependencies` (ordering), `scopes` (subset), and an approval-gate step. Each step also
|
|
65
|
+
carries a `tier` annotation the authority stores and the value-binding phase will use.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "auth51-governance"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The auth51 governance engine — classify action consequence into tiers, synthesize guarded workflows from observed runs, and validate a proposed workflow against the tier floor at registration."
|
|
9
|
+
authors = [{ name = "unforge", email = "dev-admin@unforge.io" }]
|
|
10
|
+
license = { text = "Apache-2.0" }
|
|
11
|
+
readme = "README.md"
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dependencies = [] # stdlib only — this must be trivially portable
|
|
14
|
+
keywords = ["auth51", "agentic-authorization", "workflow", "governance", "ai-agents"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"License :: OSI Approved :: Apache Software License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Topic :: Security",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://auth51.com"
|
|
24
|
+
Repository = "https://github.com/unforge-io/auth51-governance"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/auth51_governance"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""
|
|
2
|
+
auth51-governance — the governance-time engine that turns observed runs into guarded
|
|
3
|
+
workflows, and validates a proposed workflow against the consequence-tier floor.
|
|
4
|
+
|
|
5
|
+
ONE engine, used on both sides of the trust boundary:
|
|
6
|
+
* a client (or CI, or the console) DERIVES a proposal from sampled observations;
|
|
7
|
+
* the authority VALIDATES the submitted workflow against its own policy before
|
|
8
|
+
registering it.
|
|
9
|
+
|
|
10
|
+
Tiers are OPEN (data — a configurable :class:`TierPolicy`); enforcement primitives are
|
|
11
|
+
CLOSED (:class:`GuardStrength`, what the authority can enforce). Every tier resolves to
|
|
12
|
+
a guard strength, so an enterprise can define its own tiers without ever meaning
|
|
13
|
+
something the enforcer can't honour.
|
|
14
|
+
|
|
15
|
+
Deriving/registering workflows is a governance-time act, not a runtime one — even
|
|
16
|
+
though the learning signal (observations) comes from runs. Keeping the engine here, as
|
|
17
|
+
a dependency-light shared library (like auth51-checksum), lets any client — the
|
|
18
|
+
reference workforce, a custom app on the auth51 client, or the auth51d gateway — derive
|
|
19
|
+
identically while the authority stays the single source of truth. Stdlib-only.
|
|
20
|
+
"""
|
|
21
|
+
from .model import (
|
|
22
|
+
APPROVAL_ACTION,
|
|
23
|
+
APPROVAL_STEP,
|
|
24
|
+
APPROVER_AGENT,
|
|
25
|
+
Capability,
|
|
26
|
+
Key,
|
|
27
|
+
Obs,
|
|
28
|
+
)
|
|
29
|
+
from .observe import obs_from_spans
|
|
30
|
+
from .policy import (
|
|
31
|
+
DEFAULT_POLICY,
|
|
32
|
+
EvidenceState,
|
|
33
|
+
GuardStrength,
|
|
34
|
+
TierPolicy,
|
|
35
|
+
TierRule,
|
|
36
|
+
classify_consequence,
|
|
37
|
+
is_high_consequence,
|
|
38
|
+
strength_rank,
|
|
39
|
+
)
|
|
40
|
+
from .synthesize import synthesize
|
|
41
|
+
from .validate import TierLookup, step_strength, validate_workflow
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# policy model
|
|
45
|
+
"TierPolicy", "TierRule", "GuardStrength", "EvidenceState",
|
|
46
|
+
"DEFAULT_POLICY", "classify_consequence", "is_high_consequence", "strength_rank",
|
|
47
|
+
# value types
|
|
48
|
+
"Obs", "Capability", "Key",
|
|
49
|
+
"APPROVAL_STEP", "APPROVER_AGENT", "APPROVAL_ACTION",
|
|
50
|
+
# engine
|
|
51
|
+
"obs_from_spans", "synthesize",
|
|
52
|
+
"validate_workflow", "step_strength", "TierLookup",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Neutral value types shared across the governance engine.
|
|
3
|
+
|
|
4
|
+
``Obs`` is what a run DID (one governed step). ``Capability`` is what the roster CAN
|
|
5
|
+
do (the full catalog, each tagged with its tier NAME under some policy). Both are
|
|
6
|
+
framework-agnostic: no OpenTelemetry, no LangChain, no HTTP. A client adapts its own
|
|
7
|
+
stores into these, and the engine consumes only these — that is what keeps derivation
|
|
8
|
+
client-agnostic.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
from .policy import DEFAULT_POLICY, TierPolicy
|
|
16
|
+
|
|
17
|
+
Key = Tuple[str, str] # (agent, action)
|
|
18
|
+
|
|
19
|
+
# The synthetic human-approval step shared by every red line in a workflow.
|
|
20
|
+
APPROVAL_STEP = "approval"
|
|
21
|
+
APPROVER_AGENT = "human-approver"
|
|
22
|
+
APPROVAL_ACTION = "ApproveHighConsequence"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Obs:
|
|
27
|
+
"""One governed step observed in a run: an agent performing an action, in order."""
|
|
28
|
+
|
|
29
|
+
agent: str
|
|
30
|
+
action: str
|
|
31
|
+
scopes: Tuple[str, ...] = ()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Capability:
|
|
36
|
+
"""An ``(agent, action)`` the roster CAN perform, with its consequence-tier NAME —
|
|
37
|
+
the full catalog, so a dangerous capability NEVER seen in a legit run is still
|
|
38
|
+
guarded (the red line is enumerated from what the agent COULD do, not only did).
|
|
39
|
+
|
|
40
|
+
``tier`` is a name under the governing :class:`~auth51_governance.policy.TierPolicy`
|
|
41
|
+
(tiers are open, so this is a string, not a fixed enum)."""
|
|
42
|
+
|
|
43
|
+
agent: str
|
|
44
|
+
action: str
|
|
45
|
+
scopes: Tuple[str, ...] = ()
|
|
46
|
+
tier: str = "WRITE"
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def classify(
|
|
50
|
+
cls, agent: str, action: str, scopes: Sequence[str] = (),
|
|
51
|
+
tags: Sequence[str] = (), risk: str = "",
|
|
52
|
+
*, policy: TierPolicy = DEFAULT_POLICY,
|
|
53
|
+
) -> "Capability":
|
|
54
|
+
"""Build a capability, classifying its tier from spec risk/tags then name under
|
|
55
|
+
``policy``. The one place call sites should construct a capability from raw
|
|
56
|
+
surface data."""
|
|
57
|
+
return cls(agent, action, tuple(scopes),
|
|
58
|
+
policy.classify(action, tags, risk))
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace → observations adapter.
|
|
3
|
+
|
|
4
|
+
The auth51 span format is a client contract: a governed tool call is a ``tool <name>``
|
|
5
|
+
span carrying ``auth51.tool`` (the action / operationId) and ``auth51.agent_id`` (the
|
|
6
|
+
acting agent). Any client that emits spans in that shape — the reference workforce, a
|
|
7
|
+
custom app on the auth51 client, or the auth51d gateway — produces observations the
|
|
8
|
+
engine can consume. This is the one place that knows the span shape; everything
|
|
9
|
+
downstream sees only :class:`Obs`.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import List, Sequence
|
|
14
|
+
|
|
15
|
+
from .model import Obs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def obs_from_spans(spans: Sequence[dict]) -> List[Obs]:
|
|
19
|
+
"""Extract the ordered governed steps from ONE run's spans (already ordered by
|
|
20
|
+
start time). Mint/dpop/rs spans are skipped — they nest under the tool span."""
|
|
21
|
+
out: List[Obs] = []
|
|
22
|
+
for s in spans:
|
|
23
|
+
if not str(s.get("name") or "").startswith("tool "):
|
|
24
|
+
continue
|
|
25
|
+
attrs = s.get("attributes") or {}
|
|
26
|
+
action = attrs.get("auth51.tool")
|
|
27
|
+
agent = attrs.get("auth51.agent_id")
|
|
28
|
+
if action and agent:
|
|
29
|
+
scopes = attrs.get("auth51.scopes") or ()
|
|
30
|
+
if isinstance(scopes, str):
|
|
31
|
+
scopes = tuple(x for x in scopes.replace(",", " ").split() if x)
|
|
32
|
+
out.append(Obs(agent=str(agent), action=str(action), scopes=tuple(scopes)))
|
|
33
|
+
return out
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Consequence-tier POLICY — the configurable risk model.
|
|
3
|
+
|
|
4
|
+
Tiers are open; enforcement primitives are closed. A tier is just data (a name, a
|
|
5
|
+
rank, classification rules, and a handler mapping evidence → guard strength). An
|
|
6
|
+
enterprise may define any tiers it wants, order them, and wire each to a guard. What it
|
|
7
|
+
cannot invent is a new ENFORCEMENT mechanism: every tier must resolve to a
|
|
8
|
+
:class:`GuardStrength` the Authority actually implements. That is what keeps derivation
|
|
9
|
+
and enforcement coherent — a tier can never mean something the enforcer doesn't
|
|
10
|
+
understand.
|
|
11
|
+
|
|
12
|
+
The policy is authority-owned config: :data:`DEFAULT_POLICY` ships, an enterprise
|
|
13
|
+
overrides per org/app, and it serializes (:meth:`TierPolicy.to_dict`) so a client
|
|
14
|
+
derives against the same policy it will be validated against. Stdlib-only.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from enum import Enum
|
|
21
|
+
from typing import Dict, List, Optional, Sequence, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GuardStrength(str, Enum):
|
|
25
|
+
"""The CLOSED set of enforcement primitives. A tier's handler may only choose from
|
|
26
|
+
these; the Authority has enforcement code for each. Ordered by restrictiveness."""
|
|
27
|
+
|
|
28
|
+
FREE = "free" # no guard — allowed
|
|
29
|
+
ORDERED = "ordered" # must follow its legit predecessors (+ scope subset)
|
|
30
|
+
VALUE_BOUND = "value_bound" # reserved: allowed only within derived value bounds (phase 3)
|
|
31
|
+
APPROVAL = "approval" # a human approval gate must complete first (a red line)
|
|
32
|
+
# An EXTERNAL primitive (delegate to a configured PDP) is the future escape hatch
|
|
33
|
+
# for enterprise-specific logic — a product capability, not free-form config.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_STRENGTH_RANK: Dict[GuardStrength, int] = {
|
|
37
|
+
GuardStrength.FREE: 0,
|
|
38
|
+
GuardStrength.ORDERED: 1,
|
|
39
|
+
GuardStrength.VALUE_BOUND: 2,
|
|
40
|
+
GuardStrength.APPROVAL: 3,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def strength_rank(g: GuardStrength) -> int:
|
|
45
|
+
return _STRENGTH_RANK[g]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EvidenceState(str, Enum):
|
|
49
|
+
"""How much clean evidence a capability has, which the handler branches on."""
|
|
50
|
+
|
|
51
|
+
UNOBSERVED = "unobserved" # never seen in a clean run
|
|
52
|
+
OBSERVED_UNTRUSTED = "observed_untrusted" # seen, but below min_support
|
|
53
|
+
OBSERVED_TRUSTED = "observed_trusted" # seen in >= min_support runs
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class TierRule:
|
|
58
|
+
"""One tier: a name, a rank (ordering; higher = more consequential), a handler
|
|
59
|
+
(``guards``: evidence → guard strength), an enforcement ``floor`` (the minimum the
|
|
60
|
+
Authority accepts for any step of this tier), and classification rules."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
rank: int
|
|
64
|
+
guards: Dict[EvidenceState, GuardStrength]
|
|
65
|
+
floor: GuardStrength = GuardStrength.FREE
|
|
66
|
+
risk_values: Tuple[str, ...] = () # spec risk strings that map to this tier
|
|
67
|
+
tags: Tuple[str, ...] = () # tags that classify to this tier
|
|
68
|
+
name_patterns: Tuple[str, ...] = () # regex strings matched on the action name
|
|
69
|
+
|
|
70
|
+
def guard(self, evidence: EvidenceState) -> GuardStrength:
|
|
71
|
+
return self.guards.get(evidence, self.floor)
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict:
|
|
74
|
+
return {
|
|
75
|
+
"name": self.name,
|
|
76
|
+
"rank": self.rank,
|
|
77
|
+
"guards": {k.value: v.value for k, v in self.guards.items()},
|
|
78
|
+
"floor": self.floor.value,
|
|
79
|
+
"risk_values": list(self.risk_values),
|
|
80
|
+
"tags": list(self.tags),
|
|
81
|
+
"name_patterns": list(self.name_patterns),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, d: dict) -> "TierRule":
|
|
86
|
+
return cls(
|
|
87
|
+
name=str(d["name"]),
|
|
88
|
+
rank=int(d.get("rank", 0)),
|
|
89
|
+
guards={EvidenceState(k): GuardStrength(v)
|
|
90
|
+
for k, v in (d.get("guards") or {}).items()},
|
|
91
|
+
floor=GuardStrength(d.get("floor", "free")),
|
|
92
|
+
risk_values=tuple(d.get("risk_values") or ()),
|
|
93
|
+
tags=tuple(d.get("tags") or ()),
|
|
94
|
+
name_patterns=tuple(d.get("name_patterns") or ()),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
_DEFAULT_READ_PATTERN = r"^(list|get|retrieve|describe|read|fetch|search|view|count|query|lookup)"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass
|
|
102
|
+
class TierPolicy:
|
|
103
|
+
"""An ordered set of tiers plus the classification defaults. The source of truth for
|
|
104
|
+
guard strength; derivation and register-time validation both consume it."""
|
|
105
|
+
|
|
106
|
+
tiers: List[TierRule]
|
|
107
|
+
default_tier: str = "READ"
|
|
108
|
+
read_pattern: str = _DEFAULT_READ_PATTERN
|
|
109
|
+
min_support: int = 1
|
|
110
|
+
|
|
111
|
+
def __post_init__(self) -> None:
|
|
112
|
+
self._by_name: Dict[str, TierRule] = {t.name: t for t in self.tiers}
|
|
113
|
+
self._compiled: Dict[str, List[re.Pattern]] = {
|
|
114
|
+
t.name: [re.compile(p, re.IGNORECASE) for p in t.name_patterns]
|
|
115
|
+
for t in self.tiers
|
|
116
|
+
}
|
|
117
|
+
self._read_re = re.compile(self.read_pattern, re.IGNORECASE)
|
|
118
|
+
|
|
119
|
+
# ── lookups ──
|
|
120
|
+
def tier(self, name: str) -> Optional[TierRule]:
|
|
121
|
+
return self._by_name.get(name)
|
|
122
|
+
|
|
123
|
+
def rank_of(self, name: str) -> int:
|
|
124
|
+
t = self._by_name.get(name)
|
|
125
|
+
return t.rank if t else -1
|
|
126
|
+
|
|
127
|
+
def guard_for(self, name: str, evidence: EvidenceState) -> GuardStrength:
|
|
128
|
+
t = self._by_name.get(name)
|
|
129
|
+
return t.guard(evidence) if t else GuardStrength.FREE
|
|
130
|
+
|
|
131
|
+
def floor_for(self, name: str) -> GuardStrength:
|
|
132
|
+
t = self._by_name.get(name)
|
|
133
|
+
return t.floor if t else GuardStrength.FREE
|
|
134
|
+
|
|
135
|
+
# ── classification ──
|
|
136
|
+
def classify(self, action: str, tags: Sequence[str] = (), risk: str = "") -> str:
|
|
137
|
+
"""Classify an action into a tier NAME. Precedence: an explicit spec ``risk``
|
|
138
|
+
value is authoritative; otherwise the highest-ranked tier whose tags match, or
|
|
139
|
+
(for a non-read name) whose name pattern matches. A read-verb name is classified
|
|
140
|
+
only by tags/risk, so ``ListPayouts`` stays READ unless a tag/risk raises it."""
|
|
141
|
+
rl = (risk or "").strip().lower()
|
|
142
|
+
if rl:
|
|
143
|
+
for t in self.tiers:
|
|
144
|
+
if rl in t.risk_values:
|
|
145
|
+
return t.name
|
|
146
|
+
tagset = {(x or "").strip().lower() for x in (tags or ())}
|
|
147
|
+
is_read = bool(self._read_re.search(action or ""))
|
|
148
|
+
best: Optional[TierRule] = None
|
|
149
|
+
for t in self.tiers:
|
|
150
|
+
matched = bool(tagset & set(t.tags))
|
|
151
|
+
if not matched and not is_read:
|
|
152
|
+
matched = any(p.search(action or "") for p in self._compiled[t.name])
|
|
153
|
+
if matched and (best is None or t.rank > best.rank):
|
|
154
|
+
best = t
|
|
155
|
+
return best.name if best else self.default_tier
|
|
156
|
+
|
|
157
|
+
def is_red_line_tier(self, name: str) -> bool:
|
|
158
|
+
"""Whether this tier is ever a red line (its handler can require approval, or its
|
|
159
|
+
floor is above ORDERED) — used by the back-compat boolean shim."""
|
|
160
|
+
t = self._by_name.get(name)
|
|
161
|
+
if t is None:
|
|
162
|
+
return False
|
|
163
|
+
strengths = list(t.guards.values()) + [t.floor]
|
|
164
|
+
return any(strength_rank(g) >= strength_rank(GuardStrength.APPROVAL)
|
|
165
|
+
for g in strengths)
|
|
166
|
+
|
|
167
|
+
# ── serialization ──
|
|
168
|
+
def to_dict(self) -> dict:
|
|
169
|
+
return {
|
|
170
|
+
"default_tier": self.default_tier,
|
|
171
|
+
"read_pattern": self.read_pattern,
|
|
172
|
+
"min_support": self.min_support,
|
|
173
|
+
"tiers": [t.to_dict() for t in self.tiers],
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def from_dict(cls, d: dict) -> "TierPolicy":
|
|
178
|
+
return cls(
|
|
179
|
+
tiers=[TierRule.from_dict(t) for t in (d.get("tiers") or ())],
|
|
180
|
+
default_tier=str(d.get("default_tier", "READ")),
|
|
181
|
+
read_pattern=str(d.get("read_pattern", _DEFAULT_READ_PATTERN)),
|
|
182
|
+
min_support=int(d.get("min_support", 1)),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── the default policy (ranks gapped by 10 so custom tiers can slot between) ──
|
|
187
|
+
_ALL_EVIDENCE = (
|
|
188
|
+
EvidenceState.UNOBSERVED,
|
|
189
|
+
EvidenceState.OBSERVED_UNTRUSTED,
|
|
190
|
+
EvidenceState.OBSERVED_TRUSTED,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _all(g: GuardStrength) -> Dict[EvidenceState, GuardStrength]:
|
|
195
|
+
return {e: g for e in _ALL_EVIDENCE}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _red_line_unless_trusted(trusted: GuardStrength) -> Dict[EvidenceState, GuardStrength]:
|
|
199
|
+
"""Approval until there's enough clean evidence, then downgrade to ``trusted``."""
|
|
200
|
+
return {
|
|
201
|
+
EvidenceState.UNOBSERVED: GuardStrength.APPROVAL,
|
|
202
|
+
EvidenceState.OBSERVED_UNTRUSTED: GuardStrength.APPROVAL,
|
|
203
|
+
EvidenceState.OBSERVED_TRUSTED: trusted,
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
DEFAULT_POLICY = TierPolicy(
|
|
208
|
+
default_tier="READ",
|
|
209
|
+
tiers=[
|
|
210
|
+
TierRule(
|
|
211
|
+
name="READ", rank=0, guards=_all(GuardStrength.FREE),
|
|
212
|
+
floor=GuardStrength.FREE,
|
|
213
|
+
risk_values=("read", "none", "safe"),
|
|
214
|
+
tags=("read", "readonly", "none", "safe"),
|
|
215
|
+
),
|
|
216
|
+
TierRule(
|
|
217
|
+
name="WRITE", rank=10, guards=_all(GuardStrength.FREE),
|
|
218
|
+
floor=GuardStrength.FREE,
|
|
219
|
+
risk_values=("write", "low", "medium"),
|
|
220
|
+
tags=("write", "mutation", "low", "medium"),
|
|
221
|
+
name_patterns=(r"(create|update|modify|edit|change|patch|replace|add|set|write|post|upsert)",),
|
|
222
|
+
),
|
|
223
|
+
TierRule(
|
|
224
|
+
name="SENSITIVE", rank=20,
|
|
225
|
+
guards=_red_line_unless_trusted(GuardStrength.ORDERED),
|
|
226
|
+
floor=GuardStrength.FREE,
|
|
227
|
+
risk_values=("sensitive", "high"),
|
|
228
|
+
tags=("sensitive", "pii", "account", "identity", "high"),
|
|
229
|
+
name_patterns=(
|
|
230
|
+
r"(create|update|modify|edit|change|patch|replace|add|set)[a-z0-9_]*?"
|
|
231
|
+
r"(customer|account|user|member|owner|role|permission|policy|credential|"
|
|
232
|
+
r"secret|apikey|api_key|token|password|email|phone|address|profile)",
|
|
233
|
+
r"(customer|account|user|member|owner|role|permission|policy|credential|"
|
|
234
|
+
r"secret|token|email|phone|address|profile)[a-z0-9_]*?"
|
|
235
|
+
r"(create|update|modify|edit|change|patch|replace|add|set)",
|
|
236
|
+
),
|
|
237
|
+
),
|
|
238
|
+
TierRule(
|
|
239
|
+
name="FINANCIAL", rank=30,
|
|
240
|
+
guards=_red_line_unless_trusted(GuardStrength.ORDERED), # VALUE_BOUND in phase 3
|
|
241
|
+
floor=GuardStrength.ORDERED,
|
|
242
|
+
risk_values=("financial", "payment"),
|
|
243
|
+
tags=("financial", "payment", "payout", "money", "billing", "funds"),
|
|
244
|
+
name_patterns=(
|
|
245
|
+
r"(payout|transfer|refund|charge|payment|withdraw|deposit|settle|"
|
|
246
|
+
r"capture|disburse|remit|invoice|chargeback|topup|top_up)",
|
|
247
|
+
),
|
|
248
|
+
),
|
|
249
|
+
TierRule(
|
|
250
|
+
name="SECURITY", rank=40, guards=_all(GuardStrength.APPROVAL),
|
|
251
|
+
floor=GuardStrength.APPROVAL,
|
|
252
|
+
risk_values=("security",),
|
|
253
|
+
tags=("security", "iam", "privilege", "admin", "access"),
|
|
254
|
+
name_patterns=(
|
|
255
|
+
r"(attach|detach|put|set|create|update|delete)?[a-z0-9_]*polic",
|
|
256
|
+
r"grant[a-z0-9_]*(role|permission|access|scope)",
|
|
257
|
+
r"(revoke|escalat|assumerole|impersonat)",
|
|
258
|
+
r"(disable|enable)[a-z0-9_]*mfa",
|
|
259
|
+
r"(rotate|disable)[a-z0-9_]*(key|credential|secret)",
|
|
260
|
+
r"(add|remove)[a-z0-9_]*(admin|owner|member)",
|
|
261
|
+
),
|
|
262
|
+
),
|
|
263
|
+
TierRule(
|
|
264
|
+
name="DESTRUCTIVE", rank=50, guards=_all(GuardStrength.APPROVAL),
|
|
265
|
+
floor=GuardStrength.APPROVAL,
|
|
266
|
+
risk_values=("destructive", "critical"),
|
|
267
|
+
tags=("destructive", "delete"),
|
|
268
|
+
name_patterns=(
|
|
269
|
+
r"(drop|truncate|purge|destroy|wipe|erase|deprovision)",
|
|
270
|
+
r"delete[a-z0-9_]*(database|table|bucket|volume|cluster|instance|"
|
|
271
|
+
r"account|customer|user|record|data)",
|
|
272
|
+
r"terminate[a-z0-9_]*(instance|cluster|node)",
|
|
273
|
+
),
|
|
274
|
+
),
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def classify_consequence(
|
|
280
|
+
action: str, tags: Sequence[str] = (), risk: str = "",
|
|
281
|
+
*, policy: TierPolicy = DEFAULT_POLICY,
|
|
282
|
+
) -> str:
|
|
283
|
+
"""Classify one action into a tier name under ``policy`` (default: shipped policy)."""
|
|
284
|
+
return policy.classify(action, tags, risk)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def is_high_consequence(
|
|
288
|
+
action: str, tags: Sequence[str] = (), risk: str = "",
|
|
289
|
+
*, policy: TierPolicy = DEFAULT_POLICY,
|
|
290
|
+
) -> bool:
|
|
291
|
+
"""Back-compat boolean: is this action's tier ever a red line under ``policy``?"""
|
|
292
|
+
return policy.is_red_line_tier(policy.classify(action, tags, risk))
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Workflow synthesis from observed runs + the roster capability catalog.
|
|
3
|
+
|
|
4
|
+
Concrete runs of a use case are INSTANCES; the workflow is the CLASS induced from
|
|
5
|
+
them. We mine the observed ``(agent, action)`` steps and the partial order that holds
|
|
6
|
+
across runs, then emit a GUARDED workflow whose guard STRENGTH is set by each
|
|
7
|
+
capability's tier under the governing :class:`~auth51_governance.policy.TierPolicy` —
|
|
8
|
+
not a single boolean, and not a fixed table.
|
|
9
|
+
|
|
10
|
+
The tier's handler maps evidence (unobserved / observed-untrusted / observed-trusted)
|
|
11
|
+
to a :class:`~auth51_governance.policy.GuardStrength`, which is then compiled to the
|
|
12
|
+
authority's step primitives (``dependencies`` ordering, ``scopes`` subset, and an
|
|
13
|
+
approval-gate step). Because guard strength comes from the policy, an enterprise can
|
|
14
|
+
retune how strongly any tier is guarded without touching this code.
|
|
15
|
+
|
|
16
|
+
Pure + store-agnostic. Enforce-first + guarded by default.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Dict, List, Sequence, Set, Tuple
|
|
21
|
+
|
|
22
|
+
from .model import (
|
|
23
|
+
APPROVAL_ACTION,
|
|
24
|
+
APPROVAL_STEP,
|
|
25
|
+
APPROVER_AGENT,
|
|
26
|
+
Capability,
|
|
27
|
+
Key,
|
|
28
|
+
Obs,
|
|
29
|
+
)
|
|
30
|
+
from .policy import DEFAULT_POLICY, EvidenceState, GuardStrength, TierPolicy
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _slug(s: str) -> str:
|
|
34
|
+
out = "".join(c if c.isalnum() else "_" for c in (s or "").strip().lower())
|
|
35
|
+
return out.strip("_") or "step"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def synthesize(
|
|
39
|
+
workflow_id: str,
|
|
40
|
+
runs: Sequence[Sequence[Obs]],
|
|
41
|
+
capabilities: Sequence[Capability] = (),
|
|
42
|
+
*,
|
|
43
|
+
policy: TierPolicy = DEFAULT_POLICY,
|
|
44
|
+
mode: str = "enforce",
|
|
45
|
+
control: str = "guarded",
|
|
46
|
+
min_support: int | None = None,
|
|
47
|
+
approver_agent: str = APPROVER_AGENT,
|
|
48
|
+
approval_action: str = APPROVAL_ACTION,
|
|
49
|
+
) -> dict:
|
|
50
|
+
"""Return a ``WorkflowDefinition`` dict (the shape the Authority's
|
|
51
|
+
``register/workflow`` accepts), guarded per ``policy``. ``min_support`` overrides the
|
|
52
|
+
policy's default: it is the run count a capability must reach to count as
|
|
53
|
+
OBSERVED_TRUSTED (below it, OBSERVED_UNTRUSTED — usually still a red line)."""
|
|
54
|
+
support_floor = policy.min_support if min_support is None else min_support
|
|
55
|
+
|
|
56
|
+
# ── observed steps, per-run first-occurrence index, cross-run support ──
|
|
57
|
+
observed: Dict[Key, Obs] = {}
|
|
58
|
+
run_first: List[Dict[Key, int]] = []
|
|
59
|
+
support: Dict[Key, int] = {}
|
|
60
|
+
for run in runs:
|
|
61
|
+
first: Dict[Key, int] = {}
|
|
62
|
+
for i, o in enumerate(run):
|
|
63
|
+
k = (o.agent, o.action)
|
|
64
|
+
observed.setdefault(k, o)
|
|
65
|
+
first.setdefault(k, i)
|
|
66
|
+
for k in first:
|
|
67
|
+
support[k] = support.get(k, 0) + 1
|
|
68
|
+
run_first.append(first)
|
|
69
|
+
|
|
70
|
+
keys = list(observed)
|
|
71
|
+
n = len(runs)
|
|
72
|
+
|
|
73
|
+
# ── always-before: A precedes B in EVERY run where both occur (>= 1 such run) ──
|
|
74
|
+
def always_before(a: Key, b: Key) -> bool:
|
|
75
|
+
seen_together = False
|
|
76
|
+
for first in run_first:
|
|
77
|
+
if a in first and b in first:
|
|
78
|
+
seen_together = True
|
|
79
|
+
if not first[a] < first[b]:
|
|
80
|
+
return False
|
|
81
|
+
return seen_together
|
|
82
|
+
|
|
83
|
+
preds: Dict[Key, Set[Key]] = {
|
|
84
|
+
b: {a for a in keys if a != b and always_before(a, b)} for b in keys
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
tier_of: Dict[Key, str] = {(c.agent, c.action): c.tier for c in capabilities}
|
|
88
|
+
cap_scopes: Dict[Key, Tuple[str, ...]] = {
|
|
89
|
+
(c.agent, c.action): c.scopes for c in capabilities
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ── stable, unique step names ──
|
|
93
|
+
used: Set[str] = set()
|
|
94
|
+
name_of: Dict[Key, str] = {}
|
|
95
|
+
|
|
96
|
+
def _name(agent: str, action: str) -> str:
|
|
97
|
+
base = _slug(action)
|
|
98
|
+
name = base if base not in used else f"{base}__{_slug(agent)}"
|
|
99
|
+
while name in used:
|
|
100
|
+
name += "_x"
|
|
101
|
+
used.add(name)
|
|
102
|
+
return name
|
|
103
|
+
|
|
104
|
+
for agent, action in keys:
|
|
105
|
+
name_of[(agent, action)] = _name(agent, action)
|
|
106
|
+
|
|
107
|
+
def required(k: Key) -> bool:
|
|
108
|
+
return n > 0 and all(k in first for first in run_first)
|
|
109
|
+
|
|
110
|
+
def evidence_of(k: Key, *, observed_flag: bool) -> EvidenceState:
|
|
111
|
+
if not observed_flag:
|
|
112
|
+
return EvidenceState.UNOBSERVED
|
|
113
|
+
return (EvidenceState.OBSERVED_TRUSTED
|
|
114
|
+
if support.get(k, 0) >= support_floor
|
|
115
|
+
else EvidenceState.OBSERVED_UNTRUSTED)
|
|
116
|
+
|
|
117
|
+
steps: Dict[str, dict] = {}
|
|
118
|
+
need_approval = False
|
|
119
|
+
|
|
120
|
+
def _emit(k: Key, *, agent: str, action: str, tier: str,
|
|
121
|
+
strength: GuardStrength, scopes: Sequence[str], is_required: bool) -> None:
|
|
122
|
+
nonlocal need_approval
|
|
123
|
+
deps = sorted(name_of[p] for p in preds.get(k, ())) # legit predecessors
|
|
124
|
+
value_bound = False
|
|
125
|
+
if strength is GuardStrength.FREE:
|
|
126
|
+
dependencies: List[str] = []
|
|
127
|
+
elif strength is GuardStrength.ORDERED:
|
|
128
|
+
dependencies = deps
|
|
129
|
+
elif strength is GuardStrength.VALUE_BOUND:
|
|
130
|
+
# Reserved (phase 3): ordering today, plus a marker the value-binding phase
|
|
131
|
+
# and register-time validation recognise. Enforced as ORDERED until then.
|
|
132
|
+
dependencies = deps
|
|
133
|
+
value_bound = True
|
|
134
|
+
else: # APPROVAL — a human gate must complete first (plus any legit order)
|
|
135
|
+
dependencies = sorted(set(deps) | {APPROVAL_STEP})
|
|
136
|
+
need_approval = True
|
|
137
|
+
step = {
|
|
138
|
+
"agent": agent,
|
|
139
|
+
"action": action,
|
|
140
|
+
"scopes": list(scopes),
|
|
141
|
+
"dependencies": dependencies,
|
|
142
|
+
"required": is_required,
|
|
143
|
+
"approval_gate": False,
|
|
144
|
+
"requires_approval": False,
|
|
145
|
+
"tier": tier,
|
|
146
|
+
}
|
|
147
|
+
if value_bound:
|
|
148
|
+
step["value_bound"] = True
|
|
149
|
+
steps[name_of[k]] = step
|
|
150
|
+
|
|
151
|
+
# Observed steps = the full class. Guard strength follows the tier + evidence.
|
|
152
|
+
for k in keys:
|
|
153
|
+
agent, action = k
|
|
154
|
+
tier = tier_of.get(k, policy.default_tier)
|
|
155
|
+
strength = policy.guard_for(tier, evidence_of(k, observed_flag=True))
|
|
156
|
+
_emit(k, agent=agent, action=action, tier=tier, strength=strength,
|
|
157
|
+
scopes=(observed[k].scopes or cap_scopes.get(k, ())),
|
|
158
|
+
is_required=required(k))
|
|
159
|
+
|
|
160
|
+
# Unobserved capabilities: a red line when the tier guards them above FREE; low
|
|
161
|
+
# tiers stay FREE and un-emitted so the guard set stays sparse (govern the boundary).
|
|
162
|
+
for c in capabilities:
|
|
163
|
+
k = (c.agent, c.action)
|
|
164
|
+
if k in observed:
|
|
165
|
+
continue
|
|
166
|
+
strength = policy.guard_for(c.tier, EvidenceState.UNOBSERVED)
|
|
167
|
+
if strength is GuardStrength.FREE:
|
|
168
|
+
continue
|
|
169
|
+
name_of.setdefault(k, _name(c.agent, c.action))
|
|
170
|
+
_emit(k, agent=c.agent, action=c.action, tier=c.tier, strength=strength,
|
|
171
|
+
scopes=c.scopes, is_required=False)
|
|
172
|
+
|
|
173
|
+
if need_approval:
|
|
174
|
+
steps[APPROVAL_STEP] = {
|
|
175
|
+
"agent": approver_agent,
|
|
176
|
+
"action": approval_action,
|
|
177
|
+
"scopes": [],
|
|
178
|
+
"dependencies": [],
|
|
179
|
+
"required": False,
|
|
180
|
+
"approval_gate": True,
|
|
181
|
+
"requires_approval": False,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
"workflow_id": workflow_id,
|
|
186
|
+
"workflow_type": "dag",
|
|
187
|
+
"mode": mode,
|
|
188
|
+
"control": control,
|
|
189
|
+
"steps": steps,
|
|
190
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Register-time workflow validation — the trust boundary.
|
|
3
|
+
|
|
4
|
+
Derivation is a PROPOSAL and can run anywhere: a client's local dry-run, a CI job, or
|
|
5
|
+
the console. Because a client is replaceable and untrusted, the Authority must NOT trust
|
|
6
|
+
a submitted workflow's guards. It re-checks, against its OWN policy and its stored tier
|
|
7
|
+
per capability, that each step's guard strength meets the tier's ``floor``. This is not
|
|
8
|
+
a re-derive (the Authority has no traces); it is a floor check.
|
|
9
|
+
|
|
10
|
+
A capability whose tier the Authority does not know is skipped (it can only enforce the
|
|
11
|
+
floor for capabilities it has classified). Same posture as mint: the client may PROPOSE
|
|
12
|
+
anything; the Authority decides what may register.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Callable, List, Optional
|
|
17
|
+
|
|
18
|
+
from .policy import (
|
|
19
|
+
DEFAULT_POLICY,
|
|
20
|
+
GuardStrength,
|
|
21
|
+
TierPolicy,
|
|
22
|
+
strength_rank,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# (agent, action) -> tier name, or None when the Authority hasn't classified it.
|
|
26
|
+
TierLookup = Callable[[str, str], Optional[str]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def step_strength(step: dict, approval_steps: set) -> GuardStrength:
|
|
30
|
+
"""The guard strength a stored step actually carries, read from its fields."""
|
|
31
|
+
deps = set(step.get("dependencies") or [])
|
|
32
|
+
if step.get("requires_approval") or (deps & approval_steps):
|
|
33
|
+
return GuardStrength.APPROVAL
|
|
34
|
+
if step.get("value_bound"):
|
|
35
|
+
return GuardStrength.VALUE_BOUND
|
|
36
|
+
if deps:
|
|
37
|
+
return GuardStrength.ORDERED
|
|
38
|
+
return GuardStrength.FREE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_workflow(
|
|
42
|
+
workflow: dict, tier_of: TierLookup, *, policy: TierPolicy = DEFAULT_POLICY,
|
|
43
|
+
) -> List[str]:
|
|
44
|
+
"""Return a list of floor violations (empty = OK to register). ``tier_of`` maps an
|
|
45
|
+
``(agent, action)`` to the Authority's stored tier name (or ``None`` if unknown)."""
|
|
46
|
+
steps = workflow.get("steps") or {}
|
|
47
|
+
approval_steps = {name for name, s in steps.items() if s.get("approval_gate")}
|
|
48
|
+
violations: List[str] = []
|
|
49
|
+
|
|
50
|
+
for name, s in steps.items():
|
|
51
|
+
if s.get("approval_gate"):
|
|
52
|
+
continue # the gate itself is not a governed capability
|
|
53
|
+
tier = tier_of(s.get("agent", ""), s.get("action", ""))
|
|
54
|
+
if tier is None:
|
|
55
|
+
continue
|
|
56
|
+
floor = policy.floor_for(tier)
|
|
57
|
+
actual = step_strength(s, approval_steps)
|
|
58
|
+
if strength_rank(actual) < strength_rank(floor):
|
|
59
|
+
violations.append(
|
|
60
|
+
f"step '{name}' runs {tier} action '{s.get('action')}' at guard "
|
|
61
|
+
f"'{actual.value}', below the required floor '{floor.value}'")
|
|
62
|
+
|
|
63
|
+
return violations
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The default policy: classification, precedence, the SECURITY tier, serialization,
|
|
2
|
+
and — the point of the redesign — a fully custom enterprise policy."""
|
|
3
|
+
from auth51_governance import (
|
|
4
|
+
DEFAULT_POLICY,
|
|
5
|
+
EvidenceState,
|
|
6
|
+
GuardStrength,
|
|
7
|
+
TierPolicy,
|
|
8
|
+
TierRule,
|
|
9
|
+
classify_consequence,
|
|
10
|
+
is_high_consequence,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
c = classify_consequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_reads_are_read_tier():
|
|
17
|
+
for name in ("ListPayouts", "GetCustomer", "RetrieveCharge", "SearchTransfers",
|
|
18
|
+
"describe_account", "query_ledger"):
|
|
19
|
+
assert c(name) == "READ", name
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_sensitive_nouns_lift_writes_to_sensitive():
|
|
23
|
+
assert c("UpdateCustomer") == "SENSITIVE"
|
|
24
|
+
assert c("SetUserRole") == "SENSITIVE"
|
|
25
|
+
assert c("AddTag") == "WRITE"
|
|
26
|
+
assert DEFAULT_POLICY.rank_of("UpdateCustomer" and c("UpdateCustomer")) < \
|
|
27
|
+
DEFAULT_POLICY.rank_of(c("CreatePayout"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_financial_and_destructive_and_security_tiers():
|
|
31
|
+
assert c("CreatePayout") == "FINANCIAL"
|
|
32
|
+
assert c("IssueRefund") == "FINANCIAL"
|
|
33
|
+
assert c("DropDatabase") == "DESTRUCTIVE"
|
|
34
|
+
assert c("DeleteCustomer") == "DESTRUCTIVE"
|
|
35
|
+
assert c("AttachRolePolicy") == "SECURITY"
|
|
36
|
+
assert c("RevokeAccess") == "SECURITY"
|
|
37
|
+
assert c("DisableMfa") == "SECURITY"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_explicit_spec_risk_overrides_name_even_for_a_read_name():
|
|
41
|
+
assert c("GetPayoutNow", risk="financial") == "FINANCIAL"
|
|
42
|
+
assert c("RetrieveThing", risk="destructive") == "DESTRUCTIVE"
|
|
43
|
+
assert c("CreatePayout", risk="low") == "WRITE" # spec can LOWER it too
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_tags_raise_a_read_name():
|
|
47
|
+
assert c("ListThings") == "READ"
|
|
48
|
+
assert c("ListThings", tags=["financial"]) == "FINANCIAL"
|
|
49
|
+
assert c("GetThing", tags=["destructive"]) == "DESTRUCTIVE"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_highest_rank_tier_wins():
|
|
53
|
+
# both WRITE (create) and FINANCIAL (payout) patterns match; the higher rank wins
|
|
54
|
+
assert c("CreatePayout") == "FINANCIAL"
|
|
55
|
+
assert c("DoThing", tags=["sensitive", "financial"]) == "FINANCIAL"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_high_consequence_shim():
|
|
59
|
+
assert is_high_consequence("CreatePayout") is True
|
|
60
|
+
assert is_high_consequence("UpdateCustomer") is True
|
|
61
|
+
assert is_high_consequence("DropDatabase") is True
|
|
62
|
+
assert is_high_consequence("GetCustomer") is False
|
|
63
|
+
assert is_high_consequence("AddTag") is False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_policy_serialization_roundtrip():
|
|
67
|
+
d = DEFAULT_POLICY.to_dict()
|
|
68
|
+
back = TierPolicy.from_dict(d)
|
|
69
|
+
for name in ("GetCustomer", "UpdateCustomer", "CreatePayout", "DropDatabase",
|
|
70
|
+
"AttachRolePolicy"):
|
|
71
|
+
assert back.classify(name) == DEFAULT_POLICY.classify(name), name
|
|
72
|
+
assert back.floor_for("FINANCIAL") == GuardStrength.ORDERED
|
|
73
|
+
assert back.floor_for("DESTRUCTIVE") == GuardStrength.APPROVAL
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_custom_enterprise_policy_defines_new_tiers():
|
|
77
|
+
"""An enterprise can define entirely new tiers + handlers; the engine honours them
|
|
78
|
+
without any code change here — the flexibility the design promises."""
|
|
79
|
+
approval_all = {e: GuardStrength.APPROVAL for e in EvidenceState}
|
|
80
|
+
custom = TierPolicy(
|
|
81
|
+
default_tier="PUBLIC",
|
|
82
|
+
tiers=[
|
|
83
|
+
TierRule("PUBLIC", 0, {e: GuardStrength.FREE for e in EvidenceState},
|
|
84
|
+
floor=GuardStrength.FREE),
|
|
85
|
+
TierRule("INTERNAL", 10, {e: GuardStrength.FREE for e in EvidenceState},
|
|
86
|
+
floor=GuardStrength.FREE,
|
|
87
|
+
name_patterns=(r"(create|update|set)",)),
|
|
88
|
+
TierRule("PHI_EXPORT", 90, approval_all, floor=GuardStrength.APPROVAL,
|
|
89
|
+
tags=("phi", "health"),
|
|
90
|
+
name_patterns=(r"(disclose|export)[a-z0-9_]*(record|patient|phi)",)),
|
|
91
|
+
],
|
|
92
|
+
)
|
|
93
|
+
assert custom.classify("ExportPatientRecord") == "PHI_EXPORT"
|
|
94
|
+
assert custom.classify("DisclosePhi", tags=["health"]) == "PHI_EXPORT"
|
|
95
|
+
assert custom.classify("GetLedger") == "PUBLIC" # falls to default
|
|
96
|
+
assert custom.classify("UpdateThing") == "INTERNAL"
|
|
97
|
+
# a new tier's floor is enforced exactly like a built-in one
|
|
98
|
+
assert custom.floor_for("PHI_EXPORT") == GuardStrength.APPROVAL
|
|
99
|
+
# and it roundtrips through serialization (so the authority can store it)
|
|
100
|
+
assert TierPolicy.from_dict(custom.to_dict()).classify("ExportPatientRecord") == "PHI_EXPORT"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The guard ladder in synthesis: tier + evidence → guard strength (policy-driven)."""
|
|
2
|
+
from auth51_governance import (
|
|
3
|
+
APPROVAL_STEP,
|
|
4
|
+
Capability,
|
|
5
|
+
EvidenceState,
|
|
6
|
+
GuardStrength,
|
|
7
|
+
Obs,
|
|
8
|
+
TierPolicy,
|
|
9
|
+
TierRule,
|
|
10
|
+
synthesize,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _cap(agent, action, tier, scopes=()):
|
|
15
|
+
return Capability(agent, action, tuple(scopes), tier)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _deps(wf, action):
|
|
19
|
+
for s in wf["steps"].values():
|
|
20
|
+
if s["action"] == action:
|
|
21
|
+
return set(s["dependencies"])
|
|
22
|
+
raise AssertionError(f"no step for {action}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _step(wf, action):
|
|
26
|
+
for s in wf["steps"].values():
|
|
27
|
+
if s["action"] == action:
|
|
28
|
+
return s
|
|
29
|
+
raise AssertionError(action)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _has_step(wf, action):
|
|
33
|
+
return any(s["action"] == action for s in wf["steps"].values())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_reproduces_stripe_refund_screenshot():
|
|
37
|
+
"""The exact console case: 2 clean runs observed only RetrieveCustomer; the roster's
|
|
38
|
+
dangerous ops (never observed) become red lines behind approval."""
|
|
39
|
+
caps = [
|
|
40
|
+
_cap("refunder", "RetrieveCustomer", "READ"),
|
|
41
|
+
_cap("refunder", "UpdateCustomer", "SENSITIVE"),
|
|
42
|
+
_cap("refunder", "CreateExternalAccount", "FINANCIAL"),
|
|
43
|
+
_cap("refunder", "CreatePayout", "FINANCIAL"),
|
|
44
|
+
_cap("refunder", "CreateTransfer", "FINANCIAL"),
|
|
45
|
+
]
|
|
46
|
+
runs = [[Obs("refunder", "RetrieveCustomer")],
|
|
47
|
+
[Obs("refunder", "RetrieveCustomer")]]
|
|
48
|
+
wf = synthesize("refund-flow", runs, caps)
|
|
49
|
+
|
|
50
|
+
assert _deps(wf, "RetrieveCustomer") == set() # observed read → free
|
|
51
|
+
for action in ("UpdateCustomer", "CreateExternalAccount", "CreatePayout", "CreateTransfer"):
|
|
52
|
+
assert APPROVAL_STEP in _deps(wf, action), action # unobserved red lines → approval
|
|
53
|
+
assert wf["steps"][APPROVAL_STEP]["approval_gate"] is True
|
|
54
|
+
assert wf["mode"] == "enforce" and wf["control"] == "guarded"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_free_tiers_when_unobserved_emit_no_step():
|
|
58
|
+
caps = [
|
|
59
|
+
_cap("a", "GetThing", "READ"),
|
|
60
|
+
_cap("a", "AddTag", "WRITE"),
|
|
61
|
+
_cap("a", "CreatePayout", "FINANCIAL"),
|
|
62
|
+
]
|
|
63
|
+
wf = synthesize("w", [], caps)
|
|
64
|
+
assert not _has_step(wf, "GetThing")
|
|
65
|
+
assert not _has_step(wf, "AddTag")
|
|
66
|
+
assert _has_step(wf, "CreatePayout")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_observed_sensitive_is_ordered_not_approval():
|
|
70
|
+
caps = [_cap("a", "GetCase", "READ"), _cap("a", "UpdateCustomer", "SENSITIVE")]
|
|
71
|
+
runs = [[Obs("a", "GetCase"), Obs("a", "UpdateCustomer")]]
|
|
72
|
+
wf = synthesize("w", runs, caps)
|
|
73
|
+
deps = _deps(wf, "UpdateCustomer")
|
|
74
|
+
assert APPROVAL_STEP not in deps and "getcase" in deps
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_observed_financial_is_ordered_never_free():
|
|
78
|
+
caps = [_cap("a", "GetCase", "READ"), _cap("a", "IssueRefund", "FINANCIAL")]
|
|
79
|
+
runs = [[Obs("a", "GetCase"), Obs("a", "IssueRefund")]]
|
|
80
|
+
wf = synthesize("w", runs, caps)
|
|
81
|
+
assert _deps(wf, "IssueRefund") == {"getcase"}
|
|
82
|
+
assert _step(wf, "IssueRefund")["tier"] == "FINANCIAL"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_destructive_is_approval_even_when_observed():
|
|
86
|
+
"""#4 fix: a destructive op seen in clean runs still needs a human."""
|
|
87
|
+
caps = [_cap("a", "GetCase", "READ"), _cap("a", "DropDatabase", "DESTRUCTIVE")]
|
|
88
|
+
runs = [[Obs("a", "GetCase"), Obs("a", "DropDatabase")]] * 5
|
|
89
|
+
wf = synthesize("w", runs, caps)
|
|
90
|
+
assert APPROVAL_STEP in _deps(wf, "DropDatabase")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_min_support_keeps_under_evidenced_financial_a_red_line():
|
|
94
|
+
caps = [_cap("a", "IssueRefund", "FINANCIAL")]
|
|
95
|
+
runs = [[Obs("a", "IssueRefund")]]
|
|
96
|
+
assert APPROVAL_STEP not in _deps(synthesize("w", runs, caps, min_support=1), "IssueRefund")
|
|
97
|
+
assert APPROVAL_STEP in _deps(synthesize("w", runs, caps, min_support=2), "IssueRefund")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_step_shape_matches_authority_fields():
|
|
101
|
+
caps = [_cap("a", "CreatePayout", "FINANCIAL")]
|
|
102
|
+
wf = synthesize("w", [], caps)
|
|
103
|
+
step = _step(wf, "CreatePayout")
|
|
104
|
+
for key in ("agent", "action", "scopes", "dependencies", "required",
|
|
105
|
+
"approval_gate", "requires_approval", "tier"):
|
|
106
|
+
assert key in step, key
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_custom_policy_controls_guard_strength():
|
|
110
|
+
"""A custom policy that value-binds an observed financial op instead of just
|
|
111
|
+
ordering it — synthesis honours the policy's handler without code changes here."""
|
|
112
|
+
policy = TierPolicy(
|
|
113
|
+
default_tier="READ",
|
|
114
|
+
tiers=[
|
|
115
|
+
TierRule("READ", 0, {e: GuardStrength.FREE for e in EvidenceState}),
|
|
116
|
+
TierRule("MONEY", 30, {
|
|
117
|
+
EvidenceState.UNOBSERVED: GuardStrength.APPROVAL,
|
|
118
|
+
EvidenceState.OBSERVED_UNTRUSTED: GuardStrength.APPROVAL,
|
|
119
|
+
EvidenceState.OBSERVED_TRUSTED: GuardStrength.VALUE_BOUND,
|
|
120
|
+
}, floor=GuardStrength.VALUE_BOUND, tags=("money",)),
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
caps = [Capability("a", "GetCase", (), "READ"),
|
|
124
|
+
Capability("a", "SendMoney", (), "MONEY")]
|
|
125
|
+
runs = [[Obs("a", "GetCase"), Obs("a", "SendMoney")]]
|
|
126
|
+
wf = synthesize("w", runs, caps, policy=policy)
|
|
127
|
+
step = _step(wf, "SendMoney")
|
|
128
|
+
assert step["value_bound"] is True # policy chose VALUE_BOUND
|
|
129
|
+
assert APPROVAL_STEP not in set(step["dependencies"])
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Register-time floor: the authority rejects a workflow whose step guard is below the
|
|
2
|
+
tier's floor, independent of who derived it."""
|
|
3
|
+
from auth51_governance import Capability, Obs, synthesize, validate_workflow
|
|
4
|
+
|
|
5
|
+
# The authority's stored tier per capability (names under its policy).
|
|
6
|
+
_TIERS = {
|
|
7
|
+
("a", "GetThing"): "READ",
|
|
8
|
+
("a", "UpdateCustomer"): "SENSITIVE",
|
|
9
|
+
("a", "CreatePayout"): "FINANCIAL",
|
|
10
|
+
("a", "DropDatabase"): "DESTRUCTIVE",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _tier_of(agent, action):
|
|
15
|
+
return _TIERS.get((agent, action))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_synthesized_workflow_passes_its_own_floor():
|
|
19
|
+
caps = [Capability(a, act, (), t) for (a, act), t in _TIERS.items()]
|
|
20
|
+
wf = synthesize("w", [[Obs("a", "GetThing")]], caps)
|
|
21
|
+
assert validate_workflow(wf, _tier_of) == []
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_free_destructive_is_rejected():
|
|
25
|
+
wf = {"steps": {"drop": {"agent": "a", "action": "DropDatabase",
|
|
26
|
+
"dependencies": [], "approval_gate": False,
|
|
27
|
+
"requires_approval": False}}}
|
|
28
|
+
v = validate_workflow(wf, _tier_of)
|
|
29
|
+
assert len(v) == 1 and "DESTRUCTIVE" in v[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_ordered_only_destructive_is_still_rejected():
|
|
33
|
+
wf = {"steps": {
|
|
34
|
+
"drop": {"agent": "a", "action": "DropDatabase", "dependencies": ["prep"]},
|
|
35
|
+
"prep": {"agent": "a", "action": "GetThing", "dependencies": []},
|
|
36
|
+
}}
|
|
37
|
+
assert validate_workflow(wf, _tier_of) # ORDERED < APPROVAL floor
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_free_financial_rejected_but_ordered_passes():
|
|
41
|
+
free = {"steps": {"pay": {"agent": "a", "action": "CreatePayout",
|
|
42
|
+
"dependencies": []}}}
|
|
43
|
+
ordered = {"steps": {
|
|
44
|
+
"pay": {"agent": "a", "action": "CreatePayout", "dependencies": ["prep"]},
|
|
45
|
+
"prep": {"agent": "a", "action": "GetThing", "dependencies": []},
|
|
46
|
+
}}
|
|
47
|
+
assert validate_workflow(free, _tier_of)
|
|
48
|
+
assert validate_workflow(ordered, _tier_of) == []
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_approval_gated_destructive_passes():
|
|
52
|
+
wf = {"steps": {
|
|
53
|
+
"drop": {"agent": "a", "action": "DropDatabase", "dependencies": ["approval"]},
|
|
54
|
+
"approval": {"agent": "human", "action": "Approve", "dependencies": [],
|
|
55
|
+
"approval_gate": True},
|
|
56
|
+
}}
|
|
57
|
+
assert validate_workflow(wf, _tier_of) == []
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_requires_approval_flag_also_satisfies_the_gate():
|
|
61
|
+
wf = {"steps": {"drop": {"agent": "a", "action": "DropDatabase",
|
|
62
|
+
"dependencies": [], "requires_approval": True}}}
|
|
63
|
+
assert validate_workflow(wf, _tier_of) == []
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_sensitive_free_is_allowed_by_default_floor():
|
|
67
|
+
# SENSITIVE's floor is FREE (recoverable); it's ordered by derivation, not mandated
|
|
68
|
+
wf = {"steps": {"upd": {"agent": "a", "action": "UpdateCustomer",
|
|
69
|
+
"dependencies": []}}}
|
|
70
|
+
assert validate_workflow(wf, _tier_of) == []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_unknown_capability_tier_is_skipped():
|
|
74
|
+
wf = {"steps": {"x": {"agent": "z", "action": "MysteryOp", "dependencies": []}}}
|
|
75
|
+
assert validate_workflow(wf, _tier_of) == []
|