T05: the lab and its labelled mutation catalogue
lab/app.py (users, tenants, auth, resources, sharing, read/write, revoke, audit), lab/http_api.py (JSON API + browser UI, stdlib only), 20 labelled composable version-stamped mutations, ground-truth matrix. 48 tests pass. Detection against the reference scenario: MECHANICAL 0/10 flagged (correct), DEFECT 6/6, SEMANTIC 2/4 with both inert cases declared. - F-0002: M16 and M18 initially escaped detection entirely. A use case protects exactly what it asserts. Resolved by adding two claims already stated as intent in INTENT.md; the six-mutation catalogue would never have surfaced this. - test-id axis added: stable selectors survive most UI mutations, which would make H-001 trivially false. Mutations now vary on preserves_test_ids so the hypothesis is analysed split by that axis rather than rigged. - M12 (semantic deferred revoke) and M19 (defect race) are behaviourally identical and asserted as such - the discrimination problem as a test. lab/minimal.py removed; superseded by lab/app.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 1629012@bnt-lap001 Assistant-Session: 78d4fb13-8a1e-474b-87a3-9b9261c49a39
This commit is contained in:
parent
4d3421ca57
commit
4ddb2f896c
22 changed files with 1091 additions and 207 deletions
309
lab/app.py
Normal file
309
lab/app.py
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
"""The test-driver lab — the system under test.
|
||||
|
||||
Grown from the T04 seed (`lab/minimal.py`, now removed). Users belong to
|
||||
tenants, own resources, share them with READ or WRITE permission, and revoke
|
||||
that sharing. Every state change is audited.
|
||||
|
||||
Two access paths exist, and the distinction is the whole point:
|
||||
|
||||
* the **enforcement path** (`request`) is what an actor uses. It checks
|
||||
authorization and can therefore be *wrong* — that is where a seeded
|
||||
authorization defect lives.
|
||||
* the **observation channel** (`ObservationChannel`) reads stored state directly
|
||||
and probes enforcement out-of-band. It is the independent channel required by
|
||||
decision D-07.
|
||||
|
||||
An oracle consulting only stored state would verify test-driver's own
|
||||
reimplementation of the rules rather than the system's enforcement of them. An
|
||||
oracle consulting only enforcement could not notice that record and enforcement
|
||||
disagree. test-driver observes both and treats disagreement as meaningful in
|
||||
itself — that disagreement is the precise signature of an authorization defect
|
||||
which leaves the audit trail looking correct.
|
||||
|
||||
Mutations are applied by `lab.mutations.build_lab`, never by editing this file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Literal
|
||||
|
||||
Permission = Literal["READ", "WRITE"]
|
||||
|
||||
BASE_VERSION = "lab-0.2.0"
|
||||
|
||||
|
||||
class Denied(Exception):
|
||||
"""The enforcement path refused the request."""
|
||||
|
||||
def __init__(self, reason: str, status: int = 403) -> None:
|
||||
super().__init__(reason)
|
||||
self.status = status
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuditRecord:
|
||||
sequence: int
|
||||
event: str
|
||||
actor_id: str
|
||||
resource_id: str | None = None
|
||||
subject_id: str | None = None
|
||||
permission: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class User:
|
||||
id: str
|
||||
tenant_id: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LabApp:
|
||||
"""In-process resource-sharing service.
|
||||
|
||||
Mutation hooks are plain attributes holding callables or flags. A mutation
|
||||
replaces one, so that every mutated build differs from baseline in exactly
|
||||
one named way and the difference is inspectable at runtime.
|
||||
"""
|
||||
|
||||
version: str = f"{BASE_VERSION}-baseline"
|
||||
applied_mutations: tuple[str, ...] = ()
|
||||
|
||||
users: dict[str, User] = field(default_factory=dict)
|
||||
_tokens: dict[str, str] = field(default_factory=dict)
|
||||
resources: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
grants: dict[tuple[str, str], Permission] = field(default_factory=dict)
|
||||
audit: list[AuditRecord] = field(default_factory=list)
|
||||
_seq: int = 0
|
||||
|
||||
# --- mutation hooks (baseline behaviour) ----------------------------
|
||||
may_read: Callable[["LabApp", str, str], bool] | None = None
|
||||
grant_permission_for: Callable[[str | None], Permission] | None = None
|
||||
audit_revoke: bool = True
|
||||
revoke_delay_seconds: float = 0.0
|
||||
denied_status: int = 403
|
||||
require_share_acceptance: bool = False
|
||||
enforce_tenant_isolation: bool = True
|
||||
response_id_field: str = "resource_id"
|
||||
latency_seconds: float = 0.0
|
||||
|
||||
# --- presentation hooks (surface only; never affect domain semantics) ---
|
||||
ui_share_control: str = "inline"
|
||||
ui_dom_style: str = "flat"
|
||||
ui_labels: str = "plain"
|
||||
ui_field_order: str = "natural"
|
||||
ui_button_element: str = "button"
|
||||
ui_test_ids: str = "stable"
|
||||
ui_confirm_revoke: bool = False
|
||||
api_path_style: str = "long"
|
||||
tenant_sharing_announced: bool = False
|
||||
|
||||
# -- setup -----------------------------------------------------------
|
||||
|
||||
def add_user(self, user_id: str, tenant_id: str = "t-acme") -> str:
|
||||
token = f"tok-{user_id}"
|
||||
self.users[user_id] = User(user_id, tenant_id, token)
|
||||
self._tokens[token] = user_id
|
||||
return token
|
||||
|
||||
def _audit(self, event: str, actor_id: str, **kw: Any) -> None:
|
||||
self._seq += 1
|
||||
self.audit.append(AuditRecord(self._seq, event, actor_id, **kw))
|
||||
|
||||
def _whoami(self, token: str) -> str:
|
||||
if token not in self._tokens:
|
||||
raise Denied("unknown token", status=401)
|
||||
return self._tokens[token]
|
||||
|
||||
def tenant_of(self, user_id: str) -> str | None:
|
||||
user = self.users.get(user_id)
|
||||
return user.tenant_id if user else None
|
||||
|
||||
# -- enforcement path (what actors use) ------------------------------
|
||||
|
||||
def request(self, token: str, op: str, **args: Any) -> Any:
|
||||
"""The single entry point actors go through. Authorization is enforced here."""
|
||||
if self.latency_seconds:
|
||||
time.sleep(self.latency_seconds)
|
||||
user_id = self._whoami(token)
|
||||
handler = getattr(self, f"_op_{op}", None)
|
||||
if handler is None:
|
||||
raise Denied(f"unknown operation {op!r}", status=404)
|
||||
return handler(user_id, **args)
|
||||
|
||||
def _op_create_resource(self, user_id: str, resource_id: str, content: str) -> dict:
|
||||
self.resources[resource_id] = {"owner": user_id, "content": content}
|
||||
self._audit("create", user_id, resource_id=resource_id)
|
||||
return {self.response_id_field: resource_id}
|
||||
|
||||
def _op_read_resource(self, user_id: str, resource_id: str) -> dict:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None:
|
||||
raise Denied("no such resource", status=404)
|
||||
if not self._may_read(user_id, resource_id):
|
||||
raise Denied("not authorized to read", status=self.denied_status)
|
||||
return {self.response_id_field: resource_id, "content": resource["content"]}
|
||||
|
||||
def _op_write_resource(self, user_id: str, resource_id: str, content: str) -> dict:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None:
|
||||
raise Denied("no such resource", status=404)
|
||||
if not self._may_write(user_id, resource_id):
|
||||
raise Denied("not authorized to write", status=self.denied_status)
|
||||
resource["content"] = content
|
||||
self._audit("write", user_id, resource_id=resource_id)
|
||||
return {"written": True}
|
||||
|
||||
def _op_grant(
|
||||
self,
|
||||
user_id: str,
|
||||
resource_id: str,
|
||||
subject_id: str,
|
||||
permission: Permission | None = None,
|
||||
) -> dict:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None or resource["owner"] != user_id:
|
||||
raise Denied("only the owner may grant")
|
||||
if self.enforce_tenant_isolation:
|
||||
if self.tenant_of(subject_id) != self.tenant_of(user_id):
|
||||
raise Denied("cross-tenant sharing is not permitted")
|
||||
effective = (
|
||||
self.grant_permission_for(permission)
|
||||
if self.grant_permission_for
|
||||
else (permission or "READ")
|
||||
)
|
||||
self.grants[(resource_id, subject_id)] = effective
|
||||
if self.require_share_acceptance:
|
||||
self.resources[resource_id].setdefault("pending", set()).add(subject_id)
|
||||
self._audit(
|
||||
"grant", user_id, resource_id=resource_id,
|
||||
subject_id=subject_id, permission=effective,
|
||||
)
|
||||
return {"granted": effective}
|
||||
|
||||
def _op_accept_share(self, user_id: str, resource_id: str) -> dict:
|
||||
pending = self.resources.get(resource_id, {}).get("pending")
|
||||
if pending:
|
||||
pending.discard(user_id)
|
||||
self._audit("accept", user_id, resource_id=resource_id, subject_id=user_id)
|
||||
return {"accepted": True}
|
||||
|
||||
def _op_revoke(self, user_id: str, resource_id: str, subject_id: str) -> dict:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None or resource["owner"] != user_id:
|
||||
raise Denied("only the owner may revoke")
|
||||
if self.revoke_delay_seconds:
|
||||
self.resources[resource_id].setdefault("revoke_after", {})[subject_id] = (
|
||||
time.monotonic() + self.revoke_delay_seconds
|
||||
)
|
||||
else:
|
||||
self.grants.pop((resource_id, subject_id), None)
|
||||
if self.audit_revoke:
|
||||
self._audit("revoke", user_id, resource_id=resource_id, subject_id=subject_id)
|
||||
return {"revoked": True}
|
||||
|
||||
def _may_read(self, user_id: str, resource_id: str) -> bool:
|
||||
"""Authorization as the system actually enforces it."""
|
||||
if self.may_read is not None:
|
||||
return self.may_read(self, user_id, resource_id)
|
||||
return self.baseline_may_read(user_id, resource_id)
|
||||
|
||||
def _may_write(self, user_id: str, resource_id: str) -> bool:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None:
|
||||
return False
|
||||
if resource["owner"] == user_id:
|
||||
return True
|
||||
return self.grants.get((resource_id, user_id)) == "WRITE"
|
||||
|
||||
def baseline_may_read(self, user_id: str, resource_id: str) -> bool:
|
||||
resource = self.resources.get(resource_id)
|
||||
if resource is None:
|
||||
return False
|
||||
if resource["owner"] == user_id:
|
||||
return True
|
||||
deadline = resource.get("revoke_after", {}).get(user_id)
|
||||
if deadline is not None and time.monotonic() < deadline:
|
||||
return True # revocation is scheduled but not yet effective
|
||||
if user_id in resource.get("pending", set()):
|
||||
return False
|
||||
return (resource_id, user_id) in self.grants
|
||||
|
||||
|
||||
class ObservationChannel:
|
||||
"""Independent read access to lab state — decision D-07.
|
||||
|
||||
Bypasses authorization deliberately. This is the channel test-driver requires
|
||||
of any system under test, and the main integration burden the framework
|
||||
imposes on an adopter.
|
||||
"""
|
||||
|
||||
def __init__(self, app: LabApp) -> None:
|
||||
self._app = app
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return self._app.version
|
||||
|
||||
def state_permission(self, user_id: str, resource_id: str) -> str | None:
|
||||
"""What the stored record says, independent of any enforcement decision."""
|
||||
resource = self._app.resources.get(resource_id)
|
||||
if resource is None:
|
||||
return None
|
||||
if resource["owner"] == user_id:
|
||||
return "OWNER"
|
||||
return self._app.grants.get((resource_id, user_id))
|
||||
|
||||
def probe_read(self, user_id: str, resource_id: str) -> bool:
|
||||
"""Exercise the enforcement path out-of-band and report what it did.
|
||||
|
||||
This uses the subject's own credentials, which can look like a violation
|
||||
of actor isolation but is not: independence means the *actor's report* is
|
||||
never the evidence. The observer issues its own request and records the
|
||||
raw outcome. No actor is ever asked whether it succeeded.
|
||||
"""
|
||||
user = self._app.users.get(user_id)
|
||||
if user is None:
|
||||
return False
|
||||
try:
|
||||
self._app.request(user.token, "read_resource", resource_id=resource_id)
|
||||
except Denied:
|
||||
return False
|
||||
return True
|
||||
|
||||
def probe_write(self, user_id: str, resource_id: str) -> bool:
|
||||
"""Out-of-band probe of the write path, non-destructive on refusal."""
|
||||
user = self._app.users.get(user_id)
|
||||
if user is None:
|
||||
return False
|
||||
original = self._app.resources.get(resource_id, {}).get("content")
|
||||
try:
|
||||
self._app.request(
|
||||
user.token, "write_resource", resource_id=resource_id, content=original
|
||||
)
|
||||
except Denied:
|
||||
return False
|
||||
return True
|
||||
|
||||
def audit_events(self, resource_id: str) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"sequence": r.sequence,
|
||||
"event": r.event,
|
||||
"actor_id": r.actor_id,
|
||||
"subject_id": r.subject_id,
|
||||
"permission": r.permission,
|
||||
}
|
||||
for r in self._app.audit
|
||||
if r.resource_id == resource_id
|
||||
]
|
||||
|
||||
|
||||
def build_baseline() -> tuple[LabApp, dict[str, str]]:
|
||||
"""Known initial state, so that runs replay from the same starting point."""
|
||||
app = LabApp()
|
||||
tokens = {u: app.add_user(u) for u in ("alice", "bob", "carol")}
|
||||
tokens["mallory"] = app.add_user("mallory", tenant_id="t-other")
|
||||
return app, tokens
|
||||
Loading…
Add table
Add a link
Reference in a new issue