test-driver/lab/app.py
tegwick 4ddb2f896c 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
2026-08-22 23:31:22 +02:00

309 lines
12 KiB
Python

"""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