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:
parent
3c62bec31a
commit
376b44b65b
9 changed files with 228 additions and 78 deletions
|
|
@ -53,7 +53,7 @@ PROPOSED = {
|
|||
'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',
|
||||
'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'},
|
||||
}
|
||||
|
||||
|
|
@ -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')
|
||||
if step == 'register-client':
|
||||
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()
|
||||
else:
|
||||
desc, source = m.descriptor(), m.source_digest()
|
||||
|
|
|
|||
|
|
@ -13,18 +13,18 @@
|
|||
"params": {
|
||||
"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",
|
||||
"schema": "netkingdom.descriptor-composition-candidate.v1",
|
||||
"selection": "explicit version root 1.2=security-case-type-revisions/1.2; default root unchanged",
|
||||
"status": "not_an_admission",
|
||||
"steps": [
|
||||
{
|
||||
"candidate_descriptor_digest": "sha256:0a35e4bf7ce1b98a7538bda8b36bdce88d152880df05218020274a38c0f89c16",
|
||||
"candidate_descriptor_digest": "sha256:775dd71d7da43d16437dcdd9cba81ea6eef5803ab74e93c24d4994a764180135",
|
||||
"descriptor": {
|
||||
"actor": "human",
|
||||
"adapter": "net-kingdom/ensure-groups@v1",
|
||||
"adapter_source": "sha256:e38067a40486379620d8ea37de48d436e9e7eac193541ddd73808e98af54be32",
|
||||
"adapter_source": "sha256:691ace82aec8a3d2ba09fa8c884fcfae247397bc77644e16936b3da1e7942739",
|
||||
"allowed_created": [
|
||||
"service-group",
|
||||
"service-group-membership"
|
||||
|
|
@ -33,14 +33,14 @@
|
|||
"lane": "key-cape-oidc-login",
|
||||
"owner": "net-kingdom",
|
||||
"retry_safe": false,
|
||||
"revocation_scopes": [],
|
||||
"revocation_scopes": [
|
||||
"parent-session"
|
||||
],
|
||||
"step": "ensure-groups",
|
||||
"target_parameter": "client_id",
|
||||
"version": "warden.case.execution.v1"
|
||||
},
|
||||
"findings": [
|
||||
"refused by Warden v1 Descriptor.decode"
|
||||
],
|
||||
"findings": [],
|
||||
"owner_confirmation_required": null,
|
||||
"step": "ensure-groups",
|
||||
"type_lane": "key-cape-oidc-login",
|
||||
|
|
@ -197,30 +197,28 @@
|
|||
"type_owner": "{consumer_owner_repo}"
|
||||
},
|
||||
{
|
||||
"candidate_descriptor_digest": "sha256:366e8829dd6363e69abd817ea0d4ea966ac1ebcf994493b9bf9eee1a648a6ce0",
|
||||
"candidate_descriptor_digest": "sha256:24d1ab891bfccaac1d691245d495ced2571d940d055c5e387076bb57dc68444e",
|
||||
"descriptor": {
|
||||
"actor": "human",
|
||||
"adapter": "net-kingdom/apps-handover@v1",
|
||||
"adapter_source": "sha256:4ad0396928f71bfb57760c414237b6cfa06aea57ccca2ef1293e63ab22f2953a",
|
||||
"adapter_source": "sha256:997bde2b03e546d6a86e73f9634f272736ab76b4ff6d2c4ad592f4efd890d352",
|
||||
"allowed_created": [
|
||||
"handover-receipt"
|
||||
],
|
||||
"effect": "change",
|
||||
"lane": "apps-consumer-handover",
|
||||
"owner": "apps",
|
||||
"owner": "railiance-apps",
|
||||
"retry_safe": false,
|
||||
"revocation_scopes": [],
|
||||
"revocation_scopes": [
|
||||
"parent-session"
|
||||
],
|
||||
"step": "hand-over",
|
||||
"target_parameter": "client_id",
|
||||
"version": "warden.case.execution.v1"
|
||||
},
|
||||
"findings": [
|
||||
"refused by Warden v1 Descriptor.decode",
|
||||
"owner 'apps' != type owner 'railiance-apps'"
|
||||
],
|
||||
"findings": [],
|
||||
"owner_confirmation_required": {
|
||||
"lane": "apps-consumer-handover",
|
||||
"owner": "apps",
|
||||
"schema": "netkingdom.apps-handover.attempt.v1",
|
||||
"task": "NK-WP-0048-T29"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ unexercised against any live system and is not pinned, admitted or reviewed.
|
|||
## Binding
|
||||
|
||||
- `build()` takes an admitted `ensure-groups` invocation, the exact case bytes,
|
||||
an injected provisioner, and owner `publish`/`authenticate` evidence
|
||||
capabilities.
|
||||
an injected provisioner, owner `publish`/`authenticate` evidence
|
||||
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_provenance` must be a `PostgresGroupProvenance`. Its `GroupJournalBinding`
|
||||
must name this case pin, the reviewed adapter source digest and the admitted
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ def source_digest():
|
|||
def descriptor():
|
||||
return Descriptor('warden.case.execution.v1', 'ensure-groups', 'net-kingdom', REFERENCE,
|
||||
source_digest(), 'human', 'key-cape-oidc-login', 'client_id', 'change',
|
||||
CREATED, False, ())
|
||||
CREATED, False, ('parent-session',))
|
||||
|
||||
|
||||
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,
|
||||
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.
|
||||
|
||||
provisioner exposes ensure_service_groups/revoke_service_groups and carries
|
||||
the trusted owner journal as group_provenance. publish(bytes)->identifier and
|
||||
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
|
||||
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')
|
||||
if not all(callable(getattr(provisioner, m, None)) for m in ('ensure_service_groups', 'revoke_service_groups')):
|
||||
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')
|
||||
if version_root not in SELECTABLE_TYPES:
|
||||
raise Refused('unknown version root')
|
||||
|
|
@ -150,18 +152,26 @@ def build(*, admitted: Invocation, case_source: bytes, provisioner, publish, aut
|
|||
raise Refused('owner source changed')
|
||||
|
||||
def execute(inv, rollback, created=()):
|
||||
with contained_output():
|
||||
check_attempt(inv, 'rollback' if rollback else 'run')
|
||||
if rollback:
|
||||
if not created or set(created) - set(CREATED):
|
||||
raise Refused('only journal-confirmed creations may be rolled back')
|
||||
result = provisioner.revoke_service_groups(dict(
|
||||
request, idempotency_key=key + ':revoke', ensure_idempotency_key=key,
|
||||
ensure_correlation_id=admitted.pins.case, delete_if_empty=delete_created_if_empty))
|
||||
expected = {'groups_revoked', 'groups_revoked_and_deleted'}
|
||||
else:
|
||||
result = provisioner.ensure_service_groups(dict(request, idempotency_key=key))
|
||||
expected = {'groups_ensured'}
|
||||
try:
|
||||
with contained_output():
|
||||
check_attempt(inv, 'rollback' if rollback else 'run')
|
||||
if rollback:
|
||||
if not created or set(created) - set(CREATED):
|
||||
raise Refused('only journal-confirmed creations may be rolled back')
|
||||
result = provisioner.revoke_service_groups(dict(
|
||||
request, idempotency_key=key + ':revoke', ensure_idempotency_key=key,
|
||||
ensure_correlation_id=admitted.pins.case, delete_if_empty=delete_created_if_empty))
|
||||
expected = {'groups_revoked', 'groups_revoked_and_deleted'}
|
||||
else:
|
||||
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
|
||||
or result.ensure_correlation_id != admitted.pins.case
|
||||
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,
|
||||
'created': objects, 'journal_key': key, 'status': result.status,
|
||||
'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()
|
||||
with contained_output():
|
||||
evidence = publish(payload)
|
||||
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
|
||||
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)
|
||||
records[evidence] = (inv, payload, owner)
|
||||
return owner
|
||||
|
|
|
|||
|
|
@ -4,10 +4,14 @@
|
|||
kernel, modelled on the T13 registration adapter. It covers step (f),
|
||||
Apps-owned consumer verification and the hand-over receipt.
|
||||
|
||||
- **Descriptor:** step `hand-over`, owner `apps`, actor `human`, lane
|
||||
`apps-consumer-handover`, target `client_id`, effect `change`, allowed created
|
||||
object `handover-receipt`, not retry-safe, no revocation scopes. Owner and lane
|
||||
names are candidate values for Apps owner review and T30 composition.
|
||||
- **Descriptor:** `descriptor(owner)` — step `hand-over`, owner is a parameter
|
||||
(type 1.2 declares `{consumer_owner_repo}`; the T30 composer passes it, e.g.
|
||||
`railiance-apps`), actor `human`, lane `apps-consumer-handover`, target
|
||||
`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;
|
||||
`@1.2` is reached only through an explicit `version_root='1.2'` selection.
|
||||
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`;
|
||||
`record_handover(client_id, observation_digest) -> (identifier, created)`;
|
||||
`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
|
||||
evidence the owner authenticates may be removed. An existing receipt is
|
||||
`no_change` and never rolled back; unconfirmed removal refuses and keeps the
|
||||
|
|
|
|||
|
|
@ -30,10 +30,13 @@ def source_digest():
|
|||
return digest(json.dumps(composition, sort_keys=True, separators=(',', ':')).encode())
|
||||
|
||||
|
||||
def descriptor():
|
||||
return Descriptor('warden.case.execution.v1', 'hand-over', 'apps', REFERENCE,
|
||||
def descriptor(owner):
|
||||
"""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',
|
||||
'change', (RECEIPT,), False, ())
|
||||
'change', (RECEIPT,), False, ('parent-session',))
|
||||
|
||||
|
||||
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 ()
|
||||
if not groups or not all(IDENTIFIER.fullmatch(g) for g in groups):
|
||||
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,
|
||||
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.
|
||||
|
||||
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)
|
||||
durably records the hand-over receipt; remove_handover(identifier) removes only
|
||||
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
|
||||
loss, ambiguity or publication failure require owner reconciliation.
|
||||
"""
|
||||
reviewed = source_digest()
|
||||
desc = descriptor()
|
||||
if type(admitted) is not Invocation or not all(
|
||||
isinstance(v, str) and IDENTIFIER.fullmatch(v) for v in
|
||||
(admitted.step, admitted.owner, admitted.target, admitted.actor,
|
||||
admitted.principal, admitted.lane)):
|
||||
raise Refused('bounded invocation required')
|
||||
if (admitted.step, admitted.owner, admitted.actor, admitted.lane) != (
|
||||
desc.step, desc.owner, desc.actor, desc.lane):
|
||||
desc = descriptor(admitted.owner)
|
||||
if (admitted.step, admitted.actor, admitted.lane) != (desc.step, desc.actor, desc.lane):
|
||||
raise Refused('hand-over invocation boundary differs')
|
||||
if admitted.operation != 'admission' or admitted.attempt_id:
|
||||
raise Refused('expected admission invocation')
|
||||
|
|
@ -84,12 +95,15 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
|
|||
raise Refused('descriptor pin differs')
|
||||
if not isinstance(case_acceptance, str) or digest(case_acceptance.encode()) != admitted.pins.acceptance:
|
||||
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')
|
||||
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:
|
||||
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 = {}
|
||||
creation = None
|
||||
|
||||
|
|
@ -100,21 +114,22 @@ def build(*, admitted: Invocation, case_source: bytes, case_acceptance: str,
|
|||
if source_digest() != reviewed:
|
||||
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',
|
||||
'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()
|
||||
with contained_output():
|
||||
evidence = publish(payload)
|
||||
if not isinstance(evidence, str) or not IDENTIFIER.fullmatch(evidence) or evidence in records:
|
||||
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)
|
||||
return result
|
||||
|
||||
def invoke(inv):
|
||||
nonlocal creation
|
||||
binding(inv, 'run')
|
||||
with contained_output():
|
||||
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')
|
||||
ref, created = receipt
|
||||
objects = (RECEIPT,) if created else ()
|
||||
result = finish(inv, Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects,
|
||||
{'observation': observation, 'receipt': ref})
|
||||
if created:
|
||||
creation = (inv, result, ref)
|
||||
return result
|
||||
return (Outcome.SUCCESS if created else Outcome.NO_CHANGE, objects,
|
||||
{'observation': observation, 'receipt': ref}, ref if created else None)
|
||||
|
||||
def rollback(inv, created):
|
||||
nonlocal creation
|
||||
binding(inv, 'rollback')
|
||||
if created != (RECEIPT,) or creation is None or not verify(*creation[:2]):
|
||||
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)
|
||||
if removed is not True:
|
||||
raise Refused('hand-over receipt removal unconfirmed')
|
||||
creation = None
|
||||
return finish(inv, Outcome.ROLLED_BACK, (), {'removed_receipt': ref})
|
||||
return Outcome.ROLLED_BACK, (), {'removed_receipt': ref}, None
|
||||
|
||||
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):
|
||||
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():
|
||||
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))
|
||||
|
|
|
|||
|
|
@ -102,12 +102,22 @@ class ComposeTests(unittest.TestCase):
|
|||
self.assertIsNone(doc['execution_descriptors'])
|
||||
self.assertFalse(doc['admitted'] or doc['owner_review'] or doc['dispatch_permitted'])
|
||||
findings = {r['step']: r['findings'] for r in doc['steps']}
|
||||
self.assertTrue(findings['ensure-groups'])
|
||||
self.assertTrue(findings['mint-secret'])
|
||||
self.assertTrue(any('owner' in f for f in findings['hand-over']))
|
||||
self.assertEqual(findings['register-client'], [])
|
||||
# Only mint-secret stays refused: its 'warden' actor is an owner decision.
|
||||
self.assertEqual({s for s, f in findings.items() if f}, {'mint-secret'})
|
||||
self.assertEqual(findings['mint-secret'], ['refused by Warden v1 Descriptor.decode'])
|
||||
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']}
|
||||
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):
|
||||
with self.assertRaises(Refused):
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import unittest
|
|||
from unittest.mock import patch
|
||||
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
|
||||
import yaml
|
||||
|
||||
|
|
@ -79,9 +79,10 @@ class EnsureGroupsTests(unittest.TestCase):
|
|||
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
|
||||
self.journal = self.make_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,
|
||||
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.start()
|
||||
self.addCleanup(get.stop)
|
||||
|
|
@ -94,6 +95,10 @@ class EnsureGroupsTests(unittest.TestCase):
|
|||
return PostgresGroupProvenance(connect=no_connect, binding=GroupJournalBinding(**values),
|
||||
continuity=Continuity())
|
||||
|
||||
def revoke(self, inv):
|
||||
self.revoked.append(inv)
|
||||
return 'session-revocation:' + inv.attempt_id
|
||||
|
||||
def publish(self, payload):
|
||||
ref = 'owner-evidence:' + str(len(self.evidence))
|
||||
self.evidence[ref] = payload
|
||||
|
|
@ -116,6 +121,38 @@ class EnsureGroupsTests(unittest.TestCase):
|
|||
self.assertIs(revoke['delete_if_empty'], False)
|
||||
self.assertTrue(adapter.verify(rb_inv, rolled))
|
||||
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):
|
||||
adapter = a.build(**self.kw)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import json
|
|||
from pathlib import Path
|
||||
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
|
||||
import yaml
|
||||
|
||||
|
|
@ -24,20 +24,25 @@ def case_bytes(type_=a.CANONICAL_TYPE, params=PARAMS):
|
|||
class HandoverTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
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'),
|
||||
digest(b'case-acceptance:test'), digest(b'run sheet'))
|
||||
self.admitted = Invocation('hand-over', 'apps', 'forgejo', 'human', 'founder',
|
||||
'apps-consumer-handover', pins)
|
||||
self.inv = replace(self.admitted, attempt_id='attempt-one', operation='run')
|
||||
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,
|
||||
'groups': ['forge-admins', 'forge-users'], 'login_verified': True}
|
||||
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,
|
||||
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):
|
||||
ref = 'owner-evidence:' + str(len(self.evidence))
|
||||
|
|
@ -63,6 +68,40 @@ class HandoverTests(unittest.TestCase):
|
|||
back = adapter.rollback(self.rb, (a.RECEIPT,))
|
||||
self.assertEqual(back.outcome, Outcome.ROLLED_BACK)
|
||||
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):
|
||||
self.receipts.add('handover:forgejo')
|
||||
|
|
@ -98,7 +137,17 @@ class HandoverTests(unittest.TestCase):
|
|||
admitted12 = replace(self.admitted, pins=pins)
|
||||
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, '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):
|
||||
bad = [{'case_acceptance': 'other'}, {'case_source': self.case + b'\n#'},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue