NK-WP-0048-T30F: parent-session revocation for ensure-groups and hand-over; hand-over owner parameter

- ensure-groups-adapter.py: descriptor revocation_scopes=('parent-session',);
  build() requires revoke_session and calls it in a finally block on every
  run/rollback; OwnerResult now carries revoked=True and the session
  revocation evidence (previously revoked was set to the rollback flag).
- handover-adapter.py: same revocation fix; descriptor(owner) takes the owner
  as a parameter (type 1.2 '{consumer_owner_repo}') instead of hard-coded
  'apps'; build() derives it from admitted.owner and a 1.2 case must name
  the same consumer_owner_repo.
- Tests: kernel Descriptor.decode accepts both descriptors; revocation runs
  even on failed attempts; missing revocation evidence refuses before
  publication; 1.2 owner binding.
- descriptor-composition-1.2.candidate.json regenerated (same checker digest,
  consumer_owner_repo=railiance-apps): only mint-secret remains refused.
- test_compose_1_2_descriptors.py pins that finding set and checks the
  committed artifact equals regeneration.

Outside the assigned file list (needed for the composer to pass the owner):
compose-1.2-descriptors.py passes consumer_owner to hand-over as it does
for deliver-consumer-secret, and drops the stale proposed owner 'apps'.

Not an admission, owner review or pin; mint-secret actor left for owner decision.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 903097@bnt-lap001
Assistant-Session: aa320c8f-c3fa-46e7-8541-0b58e1e45d23
This commit is contained in:
tegwick 2026-10-17 08:11:30 +02:00
parent 3c62bec31a
commit 376b44b65b
9 changed files with 228 additions and 78 deletions

View file

@ -53,7 +53,7 @@ PROPOSED = {
'bind-env': {'task': 'NK-WP-0048-T27', 'schema': 'netkingdom.keycape-bind-env.attempt.v1'}, 'bind-env': {'task': 'NK-WP-0048-T27', 'schema': 'netkingdom.keycape-bind-env.attempt.v1'},
'deliver-consumer-secret': {'task': 'NK-WP-0048-T28', 'lane': 'consumer-owner-deployment', 'deliver-consumer-secret': {'task': 'NK-WP-0048-T28', 'lane': 'consumer-owner-deployment',
'schema': 'netkingdom.consumer-secret-delivery.v1'}, 'schema': 'netkingdom.consumer-secret-delivery.v1'},
'hand-over': {'task': 'NK-WP-0048-T29', 'owner': 'apps', 'lane': 'apps-consumer-handover', 'hand-over': {'task': 'NK-WP-0048-T29', 'lane': 'apps-consumer-handover',
'schema': 'netkingdom.apps-handover.attempt.v1'}, 'schema': 'netkingdom.apps-handover.attempt.v1'},
} }
@ -108,7 +108,7 @@ def adapter_descriptors(check_config_digest, consumer_owner):
raise Refused(f'{step}: adapter has no explicit 1.2 version-root path') raise Refused(f'{step}: adapter has no explicit 1.2 version-root path')
if step == 'register-client': if step == 'register-client':
desc, source = m.descriptor(check_config_digest), m.source_digest(check_config_digest) desc, source = m.descriptor(check_config_digest), m.source_digest(check_config_digest)
elif step == 'deliver-consumer-secret': elif step in ('deliver-consumer-secret', 'hand-over'):
desc, source = m.descriptor(consumer_owner), m.source_digest() desc, source = m.descriptor(consumer_owner), m.source_digest()
else: else:
desc, source = m.descriptor(), m.source_digest() desc, source = m.descriptor(), m.source_digest()

View file

@ -13,18 +13,18 @@
"params": { "params": {
"consumer_owner_repo": "railiance-apps" "consumer_owner_repo": "railiance-apps"
}, },
"refusal": "ensure-groups: descriptor refused by the Warden v1 kernel", "refusal": "mint-secret: descriptor refused by the Warden v1 kernel",
"review_requirement": "successor candidate only: new descriptor bytes need a reviewed successor revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning", "review_requirement": "successor candidate only: new descriptor bytes need a reviewed successor revision under canon section 6 and owner review (NK-WP-0048-T16) before pinning",
"schema": "netkingdom.descriptor-composition-candidate.v1", "schema": "netkingdom.descriptor-composition-candidate.v1",
"selection": "explicit version root 1.2=security-case-type-revisions/1.2; default root unchanged", "selection": "explicit version root 1.2=security-case-type-revisions/1.2; default root unchanged",
"status": "not_an_admission", "status": "not_an_admission",
"steps": [ "steps": [
{ {
"candidate_descriptor_digest": "sha256:0a35e4bf7ce1b98a7538bda8b36bdce88d152880df05218020274a38c0f89c16", "candidate_descriptor_digest": "sha256:775dd71d7da43d16437dcdd9cba81ea6eef5803ab74e93c24d4994a764180135",
"descriptor": { "descriptor": {
"actor": "human", "actor": "human",
"adapter": "net-kingdom/ensure-groups@v1", "adapter": "net-kingdom/ensure-groups@v1",
"adapter_source": "sha256:e38067a40486379620d8ea37de48d436e9e7eac193541ddd73808e98af54be32", "adapter_source": "sha256:691ace82aec8a3d2ba09fa8c884fcfae247397bc77644e16936b3da1e7942739",
"allowed_created": [ "allowed_created": [
"service-group", "service-group",
"service-group-membership" "service-group-membership"
@ -33,14 +33,14 @@
"lane": "key-cape-oidc-login", "lane": "key-cape-oidc-login",
"owner": "net-kingdom", "owner": "net-kingdom",
"retry_safe": false, "retry_safe": false,
"revocation_scopes": [], "revocation_scopes": [
"parent-session"
],
"step": "ensure-groups", "step": "ensure-groups",
"target_parameter": "client_id", "target_parameter": "client_id",
"version": "warden.case.execution.v1" "version": "warden.case.execution.v1"
}, },
"findings": [ "findings": [],
"refused by Warden v1 Descriptor.decode"
],
"owner_confirmation_required": null, "owner_confirmation_required": null,
"step": "ensure-groups", "step": "ensure-groups",
"type_lane": "key-cape-oidc-login", "type_lane": "key-cape-oidc-login",
@ -197,30 +197,28 @@
"type_owner": "{consumer_owner_repo}" "type_owner": "{consumer_owner_repo}"
}, },
{ {
"candidate_descriptor_digest": "sha256:366e8829dd6363e69abd817ea0d4ea966ac1ebcf994493b9bf9eee1a648a6ce0", "candidate_descriptor_digest": "sha256:24d1ab891bfccaac1d691245d495ced2571d940d055c5e387076bb57dc68444e",
"descriptor": { "descriptor": {
"actor": "human", "actor": "human",
"adapter": "net-kingdom/apps-handover@v1", "adapter": "net-kingdom/apps-handover@v1",
"adapter_source": "sha256:4ad0396928f71bfb57760c414237b6cfa06aea57ccca2ef1293e63ab22f2953a", "adapter_source": "sha256:997bde2b03e546d6a86e73f9634f272736ab76b4ff6d2c4ad592f4efd890d352",
"allowed_created": [ "allowed_created": [
"handover-receipt" "handover-receipt"
], ],
"effect": "change", "effect": "change",
"lane": "apps-consumer-handover", "lane": "apps-consumer-handover",
"owner": "apps", "owner": "railiance-apps",
"retry_safe": false, "retry_safe": false,
"revocation_scopes": [], "revocation_scopes": [
"parent-session"
],
"step": "hand-over", "step": "hand-over",
"target_parameter": "client_id", "target_parameter": "client_id",
"version": "warden.case.execution.v1" "version": "warden.case.execution.v1"
}, },
"findings": [ "findings": [],
"refused by Warden v1 Descriptor.decode",
"owner 'apps' != type owner 'railiance-apps'"
],
"owner_confirmation_required": { "owner_confirmation_required": {
"lane": "apps-consumer-handover", "lane": "apps-consumer-handover",
"owner": "apps",
"schema": "netkingdom.apps-handover.attempt.v1", "schema": "netkingdom.apps-handover.attempt.v1",
"task": "NK-WP-0048-T29" "task": "NK-WP-0048-T29"
}, },

View file

@ -6,8 +6,14 @@ unexercised against any live system and is not pinned, admitted or reviewed.
## Binding ## Binding
- `build()` takes an admitted `ensure-groups` invocation, the exact case bytes, - `build()` takes an admitted `ensure-groups` invocation, the exact case bytes,
an injected provisioner, and owner `publish`/`authenticate` evidence an injected provisioner, owner `publish`/`authenticate` evidence
capabilities. capabilities and `revoke_session(invocation) -> identifier`.
- The descriptor declares revocation scope `parent-session` (the Warden v1
kernel refuses an empty scope set). `revoke_session` runs in a `finally` block
on every run and rollback, including failed ones, so the parent session always
ends; a missing or malformed revocation identifier refuses before publication.
The identifier is recorded in the evidence payload and returned as
`session_revocation_evidence` with `revoked=True`.
- Group creation binds to the T14/T21 journal seam. The provisioner's - Group creation binds to the T14/T21 journal seam. The provisioner's
`group_provenance` must be a `PostgresGroupProvenance`. Its `GroupJournalBinding` `group_provenance` must be a `PostgresGroupProvenance`. Its `GroupJournalBinding`
must name this case pin, the reviewed adapter source digest and the admitted must name this case pin, the reviewed adapter source digest and the admitted

View file

@ -50,7 +50,7 @@ def source_digest():
def descriptor(): def descriptor():
return Descriptor('warden.case.execution.v1', 'ensure-groups', 'net-kingdom', REFERENCE, return Descriptor('warden.case.execution.v1', 'ensure-groups', 'net-kingdom', REFERENCE,
source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change', source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change',
CREATED, False, ()) CREATED, False, ('parent-session',))
def group_binding(groups, members, correlation_id) -> str: def group_binding(groups, members, correlation_id) -> str:
@ -88,12 +88,14 @@ def _request(case, version_root='1.1'):
def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate, def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, authenticate,
delete_created_if_empty: bool = False, version_root: str = '1.1'): revoke_session, delete_created_if_empty: bool = False, version_root: str = '1.1'):
"""Bind one admitted case to the owner journal; return a closed kernel Adapter. """Bind one admitted case to the owner journal; return a closed kernel Adapter.
provisioner exposes ensure_service_groups/revoke_service_groups and carries provisioner exposes ensure_service_groups/revoke_service_groups and carries
the trusted owner journal as group_provenance. publish(bytes)->identifier and the trusted owner journal as group_provenance. publish(bytes)->identifier and
authenticate(identifier, bytes)->True are owner evidence capabilities. authenticate(identifier, bytes)->True are owner evidence capabilities.
revoke_session(invocation)->identifier revokes this attempt's parent session;
it runs in a finally block, so even a failed attempt ends the session.
The idempotency key and correlation derive from the case pin, so every The idempotency key and correlation derive from the case pin, so every
attempt resumes the one journal receipt and never recreates removed state. attempt resumes the one journal receipt and never recreates removed state.
@ -129,7 +131,7 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
raise Refused('owner journal binding differs from this case') raise Refused('owner journal binding differs from this case')
if not all(callable(getattr(provisioner, m, None)) for m in ('ensure_service_groups', 'revoke_service_groups')): if not all(callable(getattr(provisioner, m, None)) for m in ('ensure_service_groups', 'revoke_service_groups')):
raise Refused('owner group operations required') raise Refused('owner group operations required')
if not all(callable(x) for x in (publish, authenticate)) or type(delete_created_if_empty) is not bool: if not all(callable(x) for x in (publish, authenticate, revoke_session)) or type(delete_created_if_empty) is not bool:
raise Refused('owner evidence capabilities required') raise Refused('owner evidence capabilities required')
if version_root not in SELECTABLE_TYPES: if version_root not in SELECTABLE_TYPES:
raise Refused('unknown version root') raise Refused('unknown version root')
@ -150,18 +152,26 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
raise Refused('owner source changed') raise Refused('owner source changed')
def execute(inv, rollback, created=()): def execute(inv, rollback, created=()):
with contained_output(): try:
check_attempt(inv, 'rollback' if rollback else 'run') with contained_output():
if rollback: check_attempt(inv, 'rollback' if rollback else 'run')
if not created or set(created) - set(CREATED): if rollback:
raise Refused('only journal-confirmed creations may be rolled back') if not created or set(created) - set(CREATED):
result = provisioner.revoke_service_groups(dict( raise Refused('only journal-confirmed creations may be rolled back')
request, idempotency_key=key + ':revoke', ensure_idempotency_key=key, result = provisioner.revoke_service_groups(dict(
ensure_correlation_id=admitted.pins.case, delete_if_empty=delete_created_if_empty)) request, idempotency_key=key + ':revoke', ensure_idempotency_key=key,
expected = {'groups_revoked', 'groups_revoked_and_deleted'} ensure_correlation_id=admitted.pins.case, delete_if_empty=delete_created_if_empty))
else: expected = {'groups_revoked', 'groups_revoked_and_deleted'}
result = provisioner.ensure_service_groups(dict(request, idempotency_key=key)) else:
expected = {'groups_ensured'} result = provisioner.ensure_service_groups(dict(request, idempotency_key=key))
expected = {'groups_ensured'}
finally:
# Even failure must end the parent session; failures propagate to the
# kernel as uncertain, never safe to replay.
with contained_output():
revocation = revoke_session(inv)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
if (result.status not in expected or result.ensure_idempotency_key != key if (result.status not in expected or result.ensure_idempotency_key != key
or result.ensure_correlation_id != admitted.pins.case or result.ensure_correlation_id != admitted.pins.case
or result.external_subject != ','.join(sorted(groups))): or result.external_subject != ','.join(sorted(groups))):
@ -178,13 +188,14 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
'invocation': asdict(inv), 'source': reviewed, 'outcome': outcome.value, 'invocation': asdict(inv), 'source': reviewed, 'outcome': outcome.value,
'created': objects, 'journal_key': key, 'status': result.status, 'created': objects, 'journal_key': key, 'status': result.status,
'created_groups': list(result.created_groups), 'created_groups': list(result.created_groups),
'created_memberships': list(result.created_memberships)}, 'created_memberships': list(result.created_memberships),
'session_revocation': revocation},
sort_keys=True, separators=(',', ':')).encode() sort_keys=True, separators=(',', ':')).encode()
with contained_output(): with contained_output():
evidence = publish(payload) evidence = publish(payload)
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records: if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
raise Refused('owner evidence identifier invalid or reused') raise Refused('owner evidence identifier invalid or reused')
owner = OwnerResult(outcome, evidence, objects, rollback, owner = OwnerResult(outcome, evidence, objects, True, revocation,
attempt_id=inv.attempt_id, operation=inv.operation) attempt_id=inv.attempt_id, operation=inv.operation)
records[evidence] = (inv, payload, owner) records[evidence] = (inv, payload, owner)
return owner return owner

View file

@ -4,10 +4,14 @@
kernel, modelled on the T13 registration adapter. It covers step (f), kernel, modelled on the T13 registration adapter. It covers step (f),
Apps-owned consumer verification and the hand-over receipt. Apps-owned consumer verification and the hand-over receipt.
- **Descriptor:** step `hand-over`, owner `apps`, actor `human`, lane - **Descriptor:** `descriptor(owner)` — step `hand-over`, owner is a parameter
`apps-consumer-handover`, target `client_id`, effect `change`, allowed created (type 1.2 declares `{consumer_owner_repo}`; the T30 composer passes it, e.g.
object `handover-receipt`, not retry-safe, no revocation scopes. Owner and lane `railiance-apps`), actor `human`, lane `apps-consumer-handover`, target
names are candidate values for Apps owner review and T30 composition. `client_id`, effect `change`, allowed created object `handover-receipt`, not
retry-safe, revocation scope `parent-session` (the Warden v1 kernel refuses an
empty scope set). `build()` derives the descriptor from `admitted.owner`; a 1.2
case must carry a bounded `consumer_owner_repo` equal to it. The lane name is
still a candidate value for Apps owner review.
- **Case binding:** canonical `oidc-browser-relying-party@1.1` is the default; - **Case binding:** canonical `oidc-browser-relying-party@1.1` is the default;
`@1.2` is reached only through an explicit `version_root='1.2'` selection. `@1.2` is reached only through an explicit `version_root='1.2'` selection.
No `--allow-unimplemented` admission exists. Case, descriptor and acceptance No `--allow-unimplemented` admission exists. Case, descriptor and acceptance
@ -18,7 +22,12 @@ Apps-owned consumer verification and the hand-over receipt.
`enforces_groups=True`, the case's `groups` and `login_verified=True`; `enforces_groups=True`, the case's `groups` and `login_verified=True`;
`record_handover(client_id, observation_digest) -> (identifier, created)`; `record_handover(client_id, observation_digest) -> (identifier, created)`;
`remove_handover(identifier) -> True`; `publish(bytes) -> identifier` and `remove_handover(identifier) -> True`; `publish(bytes) -> identifier` and
`authenticate(identifier, bytes) -> True` for owner evidence. `authenticate(identifier, bytes) -> True` for owner evidence;
`revoke_session(invocation) -> identifier`, called in a `finally` block on
every run and rollback (even failed ones) so the parent session always ends.
A missing or malformed revocation identifier refuses before publication; the
identifier is recorded in the evidence payload and returned as
`session_revocation_evidence` with `revoked=True`.
- **Rollback is created-only:** only a receipt this process created and whose - **Rollback is created-only:** only a receipt this process created and whose
evidence the owner authenticates may be removed. An existing receipt is evidence the owner authenticates may be removed. An existing receipt is
`no_change` and never rolled back; unconfirmed removal refuses and keeps the `no_change` and never rolled back; unconfirmed removal refuses and keeps the

View file

@ -30,10 +30,13 @@ def source_digest():
return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode()) return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode())
def descriptor(): def descriptor(owner):
return Descriptor('warden.case.execution.v1', 'hand-over', 'apps', REFERENCE, """The owner is the case's consumer_owner_repo (type 1.2 '{consumer_owner_repo}')."""
if not isinstance(owner, str) or not IDENTIFIER.fullmatch(owner):
raise Refused('bounded consumer owner required')
return Descriptor('warden.case.execution.v1', 'hand-over', owner, REFERENCE,
source_digest(), 'human', 'apps-consumer-handover', 'client_id', source_digest(), 'human', 'apps-consumer-handover', 'client_id',
'change', (RECEIPT,), False, ()) 'change', (RECEIPT,), False, ('parent-session',))
def _case(case_source, version_root): def _case(case_source, version_root):
@ -48,12 +51,15 @@ def _case(case_source, version_root):
groups = tuple(sorted(g.strip() for g in groups.split(','))) if isinstance(groups, str) else () groups = tuple(sorted(g.strip() for g in groups.split(','))) if isinstance(groups, str) else ()
if not groups or not all(IDENTIFIER.fullmatch(g) for g in groups): if not groups or not all(IDENTIFIER.fullmatch(g) for g in groups):
raise Refused('hand-over requires access_groups the consumer enforces') raise Refused('hand-over requires access_groups the consumer enforces')
return client_id, groups owner = params.get('consumer_owner_repo')
if version_root == '1.2' and (not isinstance(owner, str) or not IDENTIFIER.fullmatch(owner)):
raise Refused('1.2 hand-over requires a bounded consumer_owner_repo')
return client_id, groups, owner if version_root == '1.2' else None
def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str, def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
verify_consumer, record_handover, remove_handover, publish, authenticate, verify_consumer, record_handover, remove_handover, publish, authenticate,
version_root: str = '1.1'): revoke_session, version_root: str = '1.1'):
"""Bind one admitted case; return a closed kernel Adapter. """Bind one admitted case; return a closed kernel Adapter.
verify_consumer(client_id, groups)->dict observes the consumer and must report verify_consumer(client_id, groups)->dict observes the consumer and must report
@ -61,19 +67,24 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
case. record_handover(client_id, observation_digest)->(identifier, created) case. record_handover(client_id, observation_digest)->(identifier, created)
durably records the hand-over receipt; remove_handover(identifier) removes only durably records the hand-over receipt; remove_handover(identifier) removes only
that receipt. publish/authenticate are owner evidence capabilities. that receipt. publish/authenticate are owner evidence capabilities.
revoke_session(invocation)->identifier revokes this attempt's parent session
in a finally block, so even a failed attempt ends the session.
The descriptor owner is admitted.owner; a 1.2 case must name the same
consumer_owner_repo. A 1.1 case carries no such parameter and is bound to
the admitted owner alone.
Only a receipt verifiably created by this process may be rolled back. Process Only a receipt verifiably created by this process may be rolled back. Process
loss, ambiguity or publication failure require owner reconciliation. loss, ambiguity or publication failure require owner reconciliation.
""" """
reviewed = source_digest() reviewed = source_digest()
desc = descriptor()
if type(admitted) is not Invocation or not all( if type(admitted) is not Invocation or not all(
isinstance(v, str) and IDENTIFIER.fullmatch(v) for v in isinstance(v, str) and IDENTIFIER.fullmatch(v) for v in
(admitted.step, admitted.owner, admitted.target, admitted.actor, (admitted.step, admitted.owner, admitted.target, admitted.actor,
admitted.principal, admitted.lane)): admitted.principal, admitted.lane)):
raise Refused('bounded invocation required') raise Refused('bounded invocation required')
if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != ( desc = descriptor(admitted.owner)
desc.step, desc.owner, desc.actor, desc.lane): if (admitted.step, admitted.actor, admitted.lane) != (desc.step, desc.actor, desc.lane):
raise Refused('hand-over invocation boundary differs') raise Refused('hand-over invocation boundary differs')
if admitted.operation != 'admission' or admitted.attempt_id: if admitted.operation != 'admission' or admitted.attempt_id:
raise Refused('expected admission invocation') raise Refused('expected admission invocation')
@ -84,12 +95,15 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
raise Refused('descriptor pin differs') raise Refused('descriptor pin differs')
if not isinstance(case_acceptance, str) or digest(case_acceptance.encode()) != admitted.pins.acceptance: if not isinstance(case_acceptance, str) or digest(case_acceptance.encode()) != admitted.pins.acceptance:
raise Refused('acceptance evidence binding differs') raise Refused('acceptance evidence binding differs')
if not all(callable(x) for x in (verify_consumer, record_handover, remove_handover, publish, authenticate)): if not all(callable(x) for x in (verify_consumer, record_handover, remove_handover, publish, authenticate,
revoke_session)):
raise Refused('owner verification, receipt and evidence capabilities required') raise Refused('owner verification, receipt and evidence capabilities required')
with contained_output(): with contained_output():
client_id, groups = _case(case_source, version_root) client_id, groups, owner = _case(case_source, version_root)
if admitted.target != client_id: if admitted.target != client_id:
raise Refused('case target differs') raise Refused('case target differs')
if owner is not None and owner != admitted.owner:
raise Refused('case consumer_owner_repo differs from admitted owner')
records = {} records = {}
creation = None creation = None
@ -100,21 +114,22 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
if source_digest() != reviewed: if source_digest() != reviewed:
raise Refused('owner source changed') raise Refused('owner source changed')
def finish(inv, outcome, objects, body): def finish(inv, outcome, objects, body, revocation):
payload = json.dumps({'schema': 'netkingdom.apps-handover.attempt.v1', payload = json.dumps({'schema': 'netkingdom.apps-handover.attempt.v1',
'invocation': asdict(inv), 'source': reviewed, 'invocation': asdict(inv), 'source': reviewed,
'outcome': outcome.value, 'created': objects, **body}, 'outcome': outcome.value, 'created': objects,
'session_revocation': revocation, **body},
sort_keys=True, separators=(',', ':')).encode() sort_keys=True, separators=(',', ':')).encode()
with contained_output(): with contained_output():
evidence = publish(payload) evidence = publish(payload)
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records: if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
raise Refused('owner evidence identifier invalid or reused') raise Refused('owner evidence identifier invalid or reused')
result = OwnerResult(outcome, evidence, objects, attempt_id=inv.attempt_id, operation=inv.operation) result = OwnerResult(outcome, evidence, objects, True, revocation,
attempt_id=inv.attempt_id, operation=inv.operation)
records[evidence] = (inv, payload, result) records[evidence] = (inv, payload, result)
return result return result
def invoke(inv): def invoke(inv):
nonlocal creation
binding(inv, 'run') binding(inv, 'run')
with contained_output(): with contained_output():
seen = verify_consumer(client_id, groups) seen = verify_consumer(client_id, groups)
@ -130,14 +145,10 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
raise Refused('hand-over receipt contract differs') raise Refused('hand-over receipt contract differs')
ref, created = receipt ref, created = receipt
objects = (RECEIPT,) if created else () objects = (RECEIPT,) if created else ()
result = finish(inv, Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects, return (Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects,
{'observation': observation, 'receipt': ref}) {'observation': observation, 'receipt': ref}, ref if created else None)
if created:
creation = (inv, result, ref)
return result
def rollback(inv, created): def rollback(inv, created):
nonlocal creation
binding(inv, 'rollback') binding(inv, 'rollback')
if created != (RECEIPT,) or creation is None or not verify(*creation[:2]): if created != (RECEIPT,) or creation is None or not verify(*creation[:2]):
raise Refused('no authenticated case-created hand-over receipt') raise Refused('no authenticated case-created hand-over receipt')
@ -146,8 +157,26 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
removed = remove_handover(ref) removed = remove_handover(ref)
if removed is not True: if removed is not True:
raise Refused('hand-over receipt removal unconfirmed') raise Refused('hand-over receipt removal unconfirmed')
creation = None return Outcome.ROLLED_BACK, (), {'removed_receipt': ref}, None
return finish(inv, Outcome.ROLLED_BACK, (), {'removed_receipt': ref})
def execute(inv, operation, created=()):
nonlocal creation
try:
outcome, objects, body, ref = (invoke(inv) if operation == 'run'
else rollback(inv, created))
finally:
# Even failure must end the parent session; failures propagate to the
# kernel as uncertain, never safe to replay.
with contained_output():
revocation = revoke_session(inv)
if not isinstance(revocation, str) or not IDENTIFIER.fullmatch(revocation):
raise Refused('session revocation evidence missing')
result = finish(inv, outcome, objects, body, revocation)
if ref is not None:
creation = (inv, result, ref)
elif operation == 'rollback':
creation = None
return result
def verify(inv, result): def verify(inv, result):
record = records.get(result.evidence) if type(result) is OwnerResult else None record = records.get(result.evidence) if type(result) is OwnerResult else None
@ -156,4 +185,5 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
with contained_output(): with contained_output():
return authenticate(result.evidence, record[1]) is True return authenticate(result.evidence, record[1]) is True
return Adapter(REFERENCE, reviewed, invoke, verify, rollback) return Adapter(REFERENCE, reviewed, lambda inv: execute(inv, 'run'), verify,
lambda inv, created: execute(inv, 'rollback', created))

View file

@ -102,12 +102,22 @@ class ComposeTests(unittest.TestCase):
self.assertIsNone(doc['execution_descriptors']) self.assertIsNone(doc['execution_descriptors'])
self.assertFalse(doc['admitted'] or doc['owner_review'] or doc['dispatch_permitted']) self.assertFalse(doc['admitted'] or doc['owner_review'] or doc['dispatch_permitted'])
findings = {r['step']: r['findings'] for r in doc['steps']} findings = {r['step']: r['findings'] for r in doc['steps']}
self.assertTrue(findings['ensure-groups']) # Only mint-secret stays refused: its 'warden' actor is an owner decision.
self.assertTrue(findings['mint-secret']) self.assertEqual({s for s, f in findings.items() if f}, {'mint-secret'})
self.assertTrue(any('owner' in f for f in findings['hand-over'])) self.assertEqual(findings['mint-secret'], ['refused by Warden v1 Descriptor.decode'])
self.assertEqual(findings['register-client'], []) self.assertIn('mint-secret', doc['refusal'])
hand_over = next(r for r in doc['steps'] if r['step'] == 'hand-over')
self.assertEqual(hand_over['descriptor']['owner'], PARAMS['consumer_owner_repo'])
self.assertNotIn('owner', hand_over['owner_confirmation_required'])
flagged = {r['step'] for r in doc['steps'] if r['owner_confirmation_required']} flagged = {r['step'] for r in doc['steps'] if r['owner_confirmation_required']}
self.assertTrue({'mint-secret', 'deliver-secret', 'bind-env', 'hand-over'} <= flagged) self.assertEqual(flagged, {'mint-secret', 'deliver-secret', 'bind-env',
'deliver-consumer-secret', 'hand-over'})
def test_committed_artifact_matches_regeneration(self):
import json
committed = json.loads((ROOT / 'descriptor-composition-1.2.candidate.json').read_text())
regenerated = c.artifact(committed['check_config_digest'], committed['params'])
self.assertEqual(json.loads(json.dumps(regenerated)), committed)
def test_adapters_need_bounded_inputs(self): def test_adapters_need_bounded_inputs(self):
with self.assertRaises(Refused): with self.assertRaises(Refused):

View file

@ -11,7 +11,7 @@ import unittest
from unittest.mock import patch from unittest.mock import patch
import uuid import uuid
from warden.case_run import Invocation, digest from warden.case_run import Descriptor, Invocation, digest
from warden.case_receipt_state import Outcome, Pins, Refused from warden.case_receipt_state import Outcome, Pins, Refused
import yaml import yaml
@ -79,9 +79,10 @@ class EnsureGroupsTests(unittest.TestCase):
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
self.journal = self.make_journal() self.journal = self.make_journal()
self.prov = FakeProvisioner(self.journal) self.prov = FakeProvisioner(self.journal)
self.evidence = {} self.evidence, self.revoked = {}, []
self.kw = dict(admitted=self.admitted, case_source=self.case, provisioner=self.prov, self.kw = dict(admitted=self.admitted, case_source=self.case, provisioner=self.prov,
publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p) publish=self.publish, authenticate=lambda r, p: self.evidence.get(r) == p,
revoke_session=self.revoke)
get = patch.object(PostgresGroupProvenance, 'get', lambda s, k, b: {'state': self.prov.state}) get = patch.object(PostgresGroupProvenance, 'get', lambda s, k, b: {'state': self.prov.state})
get.start() get.start()
self.addCleanup(get.stop) self.addCleanup(get.stop)
@ -94,6 +95,10 @@ class EnsureGroupsTests(unittest.TestCase):
return PostgresGroupProvenance(connect=no_connect, binding=GroupJournalBinding(**values), return PostgresGroupProvenance(connect=no_connect, binding=GroupJournalBinding(**values),
continuity=Continuity()) continuity=Continuity())
def revoke(self, inv):
self.revoked.append(inv)
return 'session-revocation:' + inv.attempt_id
def publish(self, payload): def publish(self, payload):
ref = 'owner-evidence:' + str(len(self.evidence)) ref = 'owner-evidence:' + str(len(self.evidence))
self.evidence[ref] = payload self.evidence[ref] = payload
@ -116,6 +121,38 @@ class EnsureGroupsTests(unittest.TestCase):
self.assertIs(revoke['delete_if_empty'], False) self.assertIs(revoke['delete_if_empty'], False)
self.assertTrue(adapter.verify(rb_inv, rolled)) self.assertTrue(adapter.verify(rb_inv, rolled))
self.assertFalse(adapter.verify(self.inv, result)) # journal now says revoked self.assertFalse(adapter.verify(self.inv, result)) # journal now says revoked
self.assertEqual(self.revoked, [self.inv, rb_inv])
for res, inv in ((result, self.inv), (rolled, rb_inv)):
self.assertIs(res.revoked, True)
self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id)
self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence])
def test_descriptor_decodes_in_warden_kernel(self):
desc = a.descriptor()
self.assertEqual(desc.revocation_scopes, ('parent-session',))
raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
self.assertEqual(Descriptor.decode(raw), desc)
def test_parent_session_revoked_even_when_attempt_fails(self):
def boom(payload):
raise RuntimeError('owner journal unavailable')
self.prov.ensure_service_groups = boom
adapter = a.build(**self.kw)
with self.assertRaises(Refused): # contained as 'owner callback failed'
adapter.invoke(self.inv)
rb_inv = replace(self.inv, attempt_id='attempt-two', operation='rollback')
with self.assertRaises(Refused):
adapter.rollback(rb_inv, ('foreign-object',))
self.assertEqual(self.revoked, [self.inv, rb_inv])
self.assertEqual(self.evidence, {})
def test_missing_revocation_evidence_refused_before_publication(self):
for bad in (None, '', 'bad id', 7):
with self.subTest(bad=bad), self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv)
self.assertEqual(self.evidence, {})
with self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': None})
def test_replay_resumes_without_creation(self): def test_replay_resumes_without_creation(self):
adapter = a.build(**self.kw) adapter = a.build(**self.kw)

View file

@ -5,7 +5,7 @@ import json
from pathlib import Path from pathlib import Path
import unittest import unittest
from warden.case_run import Invocation, OwnerResult, digest from warden.case_run import Descriptor, Invocation, OwnerResult, digest
from warden.case_receipt_state import Outcome, Pins, Refused from warden.case_receipt_state import Outcome, Pins, Refused
import yaml import yaml
@ -24,20 +24,25 @@ def case_bytes(type_=a.CANONICAL_TYPE, params=PARAMS):
class HandoverTests(unittest.TestCase): class HandoverTests(unittest.TestCase):
def setUp(self): def setUp(self):
self.case = case_bytes() self.case = case_bytes()
desc_bytes = json.dumps(asdict(a.descriptor()), sort_keys=True, separators=(',', ':')).encode() desc_bytes = json.dumps(asdict(a.descriptor('apps')), sort_keys=True, separators=(',', ':')).encode()
pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes), digest(b'catalog'), pins = Pins(digest(self.case), digest(b'type'), digest(desc_bytes), digest(b'catalog'),
digest(b'case-acceptance:test'), digest(b'run sheet')) digest(b'case-acceptance:test'), digest(b'run sheet'))
self.admitted = Invocation('hand-over', 'apps', 'forgejo', 'human', 'founder', self.admitted = Invocation('hand-over', 'apps', 'forgejo', 'human', 'founder',
'apps-consumer-handover', pins) 'apps-consumer-handover', pins)
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run') self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
self.rb = replace(self.admitted, attempt_id='attempt-two', operation='rollback') self.rb = replace(self.admitted, attempt_id='attempt-two', operation='rollback')
self.evidence, self.receipts = {}, set() self.evidence, self.receipts, self.revoked = {}, set(), []
self.observed = {'client_id': 'forgejo', 'enforces_groups': True, self.observed = {'client_id': 'forgejo', 'enforces_groups': True,
'groups': ['forge-admins', 'forge-users'], 'login_verified': True} 'groups': ['forge-admins', 'forge-users'], 'login_verified': True}
self.kw = dict(admitted=self.admitted, case_source=self.case, case_acceptance='case-acceptance:test', self.kw = dict(admitted=self.admitted, case_source=self.case, case_acceptance='case-acceptance:test',
verify_consumer=lambda c, g: dict(self.observed), record_handover=self.record, verify_consumer=lambda c, g: dict(self.observed), record_handover=self.record,
remove_handover=self.remove, publish=self.publish, remove_handover=self.remove, publish=self.publish,
authenticate=lambda ref, payload: self.evidence.get(ref) == payload) authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
revoke_session=self.revoke)
def revoke(self, inv):
self.revoked.append(inv)
return 'session-revocation:' + inv.attempt_id
def publish(self, payload): def publish(self, payload):
ref = 'owner-evidence:' + str(len(self.evidence)) ref = 'owner-evidence:' + str(len(self.evidence))
@ -63,6 +68,40 @@ class HandoverTests(unittest.TestCase):
back = adapter.rollback(self.rb, (a.RECEIPT,)) back = adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(back.outcome, Outcome.ROLLED_BACK) self.assertEqual(back.outcome, Outcome.ROLLED_BACK)
self.assertEqual(self.receipts, set()) self.assertEqual(self.receipts, set())
self.assertEqual(self.revoked, [self.inv, self.rb])
for res, inv in ((result, self.inv), (back, self.rb)):
self.assertIs(res.revoked, True)
self.assertEqual(res.session_revocation_evidence, 'session-revocation:' + inv.attempt_id)
self.assertIn(res.session_revocation_evidence.encode(), self.evidence[res.evidence])
def test_descriptor_decodes_in_warden_kernel_with_parameter_owner(self):
for owner in ('apps', 'railiance-apps'):
desc = a.descriptor(owner)
self.assertEqual((desc.owner, desc.revocation_scopes), (owner, ('parent-session',)))
raw = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
self.assertEqual(Descriptor.decode(raw), desc)
for owner in (None, '', 'bad owner', 3):
with self.subTest(owner=owner), self.assertRaises(Refused):
a.descriptor(owner)
def test_parent_session_revoked_even_when_attempt_fails(self):
self.observed['login_verified'] = False
adapter = a.build(**self.kw)
with self.assertRaises(Refused):
adapter.invoke(self.inv)
self.assertEqual(self.revoked, [self.inv])
with self.assertRaises(Refused): # nothing created: rollback refused, session still revoked
adapter.rollback(self.rb, (a.RECEIPT,))
self.assertEqual(self.revoked, [self.inv, self.rb])
self.assertEqual(self.evidence, {})
def test_missing_revocation_evidence_refused_before_publication(self):
for bad in (None, '', 'bad id', 7):
with self.subTest(bad=bad), self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': lambda inv, b=bad: b}).invoke(self.inv)
self.assertEqual(self.evidence, {})
with self.assertRaises(Refused):
a.build(**{**self.kw, 'revoke_session': None})
def test_existing_receipt_is_no_change_and_not_rollbackable(self): def test_existing_receipt_is_no_change_and_not_rollbackable(self):
self.receipts.add('handover:forgejo') self.receipts.add('handover:forgejo')
@ -98,7 +137,17 @@ class HandoverTests(unittest.TestCase):
admitted12 = replace(self.admitted, pins=pins) admitted12 = replace(self.admitted, pins=pins)
with self.assertRaises(Refused): # 1.2 never reached by default with self.assertRaises(Refused): # 1.2 never reached by default
a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12}) a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12})
a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'}) with self.assertRaises(Refused): # 1.2 case must name consumer_owner_repo
a.build(**{**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'})
case12 = case_bytes(a.SELECTABLE_TYPES['1.2'], {**PARAMS, 'consumer_owner_repo': 'railiance-apps'})
desc = json.dumps(asdict(a.descriptor('railiance-apps')), sort_keys=True, separators=(',', ':')).encode()
admitted12 = replace(self.admitted, owner='railiance-apps',
pins=replace(self.admitted.pins, case=digest(case12), adapter=digest(desc)))
kw12 = {**self.kw, 'admitted': admitted12, 'case_source': case12, 'version_root': '1.2'}
inv = replace(admitted12, attempt_id='attempt-one', operation='run')
self.assertEqual(a.build(**kw12).invoke(inv).outcome, Outcome.SUCCESS)
with self.assertRaises(Refused): # admitted owner differs from the case's consumer_owner_repo
a.build(**{**kw12, 'admitted': replace(admitted12, owner='apps')})
def test_binding_refusals(self): def test_binding_refusals(self):
bad = [{'case_acceptance': 'other'}, {'case_source': self.case + b'\n#'}, bad = [{'case_acceptance': 'other'}, {'case_source': self.case + b'\n#'},