Record reviewed case source evidence (8/13)
This commit is contained in:
parent
4743544938
commit
2d4401a425
20 changed files with 2445 additions and 0 deletions
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"status": "passed",
|
||||
"checks": [
|
||||
{
|
||||
"check": "existing_owner_validate_type",
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"check": "existing_owner_validate_case/forgejo.yaml",
|
||||
"allow_unimplemented": true,
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"check": "existing_owner_validate_case/example-public-portal.yaml",
|
||||
"allow_unimplemented": true,
|
||||
"issues": []
|
||||
},
|
||||
{
|
||||
"check": "bounded_structural_diff",
|
||||
"changed": [
|
||||
"follow_through[hand-over].entry_point",
|
||||
"follow_through[hand-over].auth",
|
||||
"rollback[hand-over].action",
|
||||
"rollback[deliver-consumer-secret].action"
|
||||
],
|
||||
"seven_descriptors_parameters_proposed_status_preserved": true
|
||||
}
|
||||
],
|
||||
"native_or_owner_acceptance": false
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"command": [
|
||||
"/home/worsch/ops-warden/.venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-r5-f3-implementation-r2/test_owner_configuration_contract.py"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"environment": {
|
||||
"PATH": "/usr/bin:/bin",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"NK_FROZEN_ROOT": "/tmp/nk48-r5-f1-ahudwokt/net-kingdom",
|
||||
"PYTHONPATH": "/tmp/nk48-r5-f1-ahudwokt/warden-frozen/src:/home/worsch/the-custodian/docs/changes/netkingdom-target-2026-10-17/nk48-r5-f3-implementation-r2"
|
||||
},
|
||||
"scope": "synthetic corrected owner protocol through unchanged frozen adapter seams; no real backend"
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
................... [100%]
|
||||
19 passed, 33 subtests passed in 0.19s
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
--- a/owner_configuration_contract.py
|
||||
+++ b/owner_configuration_contract.py
|
||||
@@ -11,7 +11,12 @@
|
||||
|
||||
def inverse(binding, transaction, owner):
|
||||
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
|
||||
- with owner.exclusive(binding):
|
||||
+ # This is a distinct operation: hand-over has already revoked its session.
|
||||
+ # Backend must authenticate a fresh attended inverse attempt, exact operation,
|
||||
+ # principal/owner/case/source/release/transaction scope and current disposition;
|
||||
+ # durably retain authority + outcome and revoke this attempt on every exit.
|
||||
+ # Durable configuration provenance alone is never execution authority.
|
||||
+ with owner.inverse_session(binding, transaction), owner.exclusive(binding):
|
||||
record = owner.authenticated_record(transaction)
|
||||
if record['binding'] != binding:
|
||||
raise Refused('configuration transaction binding differs')
|
||||
|
|
@ -0,0 +1,353 @@
|
|||
F.F.FF [100%]
|
||||
=================================== FAILURES ===================================
|
||||
____ OwnerFlowTests.test_fresh_inverse_session_exact_scope_open_and_revoked ____
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_exact_scope_open_and_revoked>
|
||||
|
||||
def test_fresh_inverse_session_exact_scope_open_and_revoked(self):
|
||||
self.setup_flow()
|
||||
transaction, _ = self.flow()
|
||||
self.assertEqual(self.revoked, [self.inv])
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
> opened, closed = self.owner.inverse_session_events
|
||||
^^^^^^^^^^^^^^
|
||||
E ValueError: not enough values to unpack (expected 2, got 0)
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:347: ValueError
|
||||
_ OwnerFlowTests.test_fresh_inverse_session_revoked_on_guard_and_persistence_failure (failure='drift') _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_guard_and_persistence_failure>
|
||||
|
||||
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
|
||||
for failure in ('drift', 'persistence'):
|
||||
with self.subTest(failure=failure):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
if failure == 'drift':
|
||||
self.owner.current['revision'] += 1
|
||||
else:
|
||||
def refuse(ident, restored):
|
||||
raise Refused('inverse persistence failed')
|
||||
self.owner.confirm_inverse = refuse
|
||||
with self.assertRaises(Refused):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
> opened, closed = self.owner.inverse_session_events
|
||||
^^^^^^^^^^^^^^
|
||||
E ValueError: not enough values to unpack (expected 2, got 0)
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:415: ValueError
|
||||
_ OwnerFlowTests.test_fresh_inverse_session_revoked_on_guard_and_persistence_failure (failure='persistence') _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_guard_and_persistence_failure>
|
||||
|
||||
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
|
||||
for failure in ('drift', 'persistence'):
|
||||
with self.subTest(failure=failure):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
if failure == 'drift':
|
||||
self.owner.current['revision'] += 1
|
||||
else:
|
||||
def refuse(ident, restored):
|
||||
raise Refused('inverse persistence failed')
|
||||
self.owner.confirm_inverse = refuse
|
||||
with self.assertRaises(Refused):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
> opened, closed = self.owner.inverse_session_events
|
||||
^^^^^^^^^^^^^^
|
||||
E ValueError: not enough values to unpack (expected 2, got 0)
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:415: ValueError
|
||||
_____ OwnerFlowTests.test_fresh_inverse_session_revoked_on_inverse_failure _____
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_fresh_inverse_session_revoked_on_inverse_failure>
|
||||
|
||||
def test_fresh_inverse_session_revoked_on_inverse_failure(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
self.owner.fail_inverse = True
|
||||
with self.assertRaisesRegex(Refused, 'owner rollback failed'):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
> opened, closed = self.owner.inverse_session_events
|
||||
^^^^^^^^^^^^^^
|
||||
E ValueError: not enough values to unpack (expected 2, got 0)
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:398: ValueError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'operation': 'hand-over'}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'transaction': 'other-transaction'}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixture-owner-attempt', 'case': 'other', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'other', 'attempt': 'fixture-owner-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...r-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'other', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_inverse_scope_mismatches_refused_without_mutation (changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'other', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) _
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_inverse_scope_mismatches_refused_without_mutation>
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
> with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:388: AssertionError
|
||||
_ OwnerFlowTests.test_missing_fresh_authority_failure_unwind_retains_original_error _
|
||||
warden.case_receipt_state.Refused: consumer verification contract differs
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_missing_fresh_authority_failure_unwind_retains_original_error>
|
||||
|
||||
def test_missing_fresh_authority_failure_unwind_retains_original_error(self):
|
||||
self.setup_flow(); self.fail_verify = True
|
||||
self.owner.inverse_authority_available = False
|
||||
> with self.assertRaisesRegex(Refused, 'fresh attended') as caught:
|
||||
E AssertionError: "fresh attended" does not match "consumer verification contract differs"
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:369: AssertionError
|
||||
____ OwnerFlowTests.test_revoked_handover_session_refused_for_later_inverse ____
|
||||
|
||||
self = <test_owner_configuration_contract.OwnerFlowTests testMethod=test_revoked_handover_session_refused_for_later_inverse>
|
||||
|
||||
def test_revoked_handover_session_refused_for_later_inverse(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
self.owner.inverse_session_id_override = self.inv.attempt_id
|
||||
before = deepcopy(self.owner.current)
|
||||
> with self.assertRaisesRegex(Refused, 'revoked session'):
|
||||
E AssertionError: Refused not raised
|
||||
|
||||
nk48-apps-f3-r2-negative/test_owner_configuration_contract.py:360: AssertionError
|
||||
=========================== short test summary info ============================
|
||||
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_exact_scope_open_and_revoked
|
||||
SUBFAILED(failure='drift') nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_guard_and_persistence_failure
|
||||
SUBFAILED(failure='persistence') nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_guard_and_persistence_failure
|
||||
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_fresh_inverse_session_revoked_on_inverse_failure
|
||||
SUBFAILED(changed={'operation': 'hand-over'}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'transaction': 'other-transaction'}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixture-owner-attempt', 'case': 'other', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'other', 'attempt': 'fixture-owner-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...r-attempt', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'other', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'fixt...', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
SUBFAILED(changed={'binding': {'acceptance': 'sha256:3e5028cf109dc045bc677e34aa63c6e809f0ce98a7c53888d78a5c7bdb865a73', 'attempt': 'other', 'case': 'sha256:2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7', 'client': 'example-portal', ...}}) nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_inverse_scope_mismatches_refused_without_mutation
|
||||
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_missing_fresh_authority_failure_unwind_retains_original_error
|
||||
FAILED nk48-apps-f3-r2-negative/test_owner_configuration_contract.py::OwnerFlowTests::test_revoked_handover_session_refused_for_later_inverse
|
||||
19 failed, 2 passed, 13 deselected in 0.50s
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"schema": "custodian.nk48-r5-f3-candidate.v1",
|
||||
"status": "corrected-source-candidate-synthetic-tested-owner-backend-absent",
|
||||
"finding": "NK48-R5-F3",
|
||||
"canonical_edits": false,
|
||||
"native_actions": false,
|
||||
"owner_assent": false,
|
||||
"source_pins": [
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "95158b1d5c15e6cf8be980f5c0e3b48aeef252654846273887f40516b3fa6a78",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "INTENT.md",
|
||||
"sha256": "fd2ba8dff9dfd5583ec6d8b0348f273bfe00c59923b5d5d47cd22b1fb4658c54",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "SECURITY-COMPANION.md",
|
||||
"sha256": "06374445ca888549f1bf79527d1345d94918d7a7ec363160d8427d0e7a31d643",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "sso-mfa/k8s/keycape/handover-adapter.py",
|
||||
"sha256": "bad7c1132258f2140b0d83548031ac86eecea4f4019f34adedc1e9e3774a3832",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "sso-mfa/k8s/keycape/deliver-consumer-secret-adapter.py",
|
||||
"sha256": "65db5386cdf08a1aa932886c046abed20330a36c96e63d4267adb8341b23da42",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml",
|
||||
"sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/examples/example-public-portal.yaml",
|
||||
"sha256": "2b30b9403ba124514e3ab8777b187a225099e2f7683fdd41d0ad48ef3d9649a7",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "net-kingdom",
|
||||
"head": "3236d5000e52e0643f33906a26a7825aa0b67465",
|
||||
"path": "security-case-type-revisions/1.3/oidc-browser-relying-party/examples/forgejo.yaml",
|
||||
"sha256": "546aad5cf26842c484fc6ba99940d56697b85531afea11872556761008923c94",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "2a366a5fbb69ba235df97ee117153cbfa1af11ab579372751b4fb5dbb30c31a7",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "INTENT.md",
|
||||
"sha256": "31060dc1738aa4540f78735019359a276ece39dad8afbf1be740713bebbe65e5",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "Makefile",
|
||||
"sha256": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md",
|
||||
"sha256": "1077e9de358433bf1316e3db1459dd271f82875b9590bf3d7b5b22c0df42bbda",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json",
|
||||
"sha256": "67e9ded8d37462ba04a0a224bac03a840d0c94326235474e1d3371c8ff61865d",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "railiance-apps",
|
||||
"head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"path": "docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md",
|
||||
"sha256": "c523949454e4c6eb58e8caee382380a561f561704136de62bf5783c592f962f2",
|
||||
"working_bytes_equal_commit": true
|
||||
},
|
||||
{
|
||||
"repo": "the-custodian",
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/nk48-adapters-1.2-review/coordinator-review-r5.json",
|
||||
"sha256": "e3016352c58178894ccbfc600b960d3f9e2b5d534a05b8df2c5b79737cb0127a",
|
||||
"historical_receipt_untouched": true
|
||||
},
|
||||
{
|
||||
"repo": "the-custodian",
|
||||
"path": "docs/changes/netkingdom-target-2026-10-17/nk48-1.3-owner-review/owner-review-railiance-apps.json",
|
||||
"sha256": "aedca85f3a742f6510c45aa17ade7375b93c46069cf4e5a7119bec9765efbd7d",
|
||||
"historical_receipt_untouched": true
|
||||
}
|
||||
],
|
||||
"kernel": "117fec9241e7b985dfb187b992e173d0a5dc3700",
|
||||
"candidate_type_before_sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a",
|
||||
"candidate_type_after_sha256": "6e744be190c4f047e609bef7670ff987fe1606b4d7dac3f740f164bebc205a13",
|
||||
"candidate_wrapper_sha256": "2cd04432cf3681afc126736f7daacfab46630441aeb223b3e222d6951d24ce2e",
|
||||
"tests": {
|
||||
"passed": 19,
|
||||
"subtests_passed": 33,
|
||||
"negative_controls_detected": [
|
||||
"mandatory fresh inverse-session guard omitted"
|
||||
]
|
||||
},
|
||||
"blocking_owner_work": "T05 real owner backend absent: exact authenticated current inverse operation authority/session lifecycle, durable journal and recovery, all-writer release exclusion and accepted configuration projection/source binding must be selected and implemented by Apps, reusing current attended owner deployment path; no source-only operational completion.",
|
||||
"proposed_type_structural_scope": [
|
||||
"follow_through[hand-over].entry_point",
|
||||
"follow_through[hand-over].auth",
|
||||
"rollback[hand-over].action",
|
||||
"rollback[deliver-consumer-secret].action"
|
||||
],
|
||||
"knowledge_orientation": {
|
||||
"package": "0.2.6",
|
||||
"digest": "8d1d6a9d4d07fec7b619618de7dfe2254c1790f85f4198c7fe19db9751c02",
|
||||
"start_and_identity": "withheld-source-changed; owner AGENTS/INTENT and direct seam/owner sources reviewed"
|
||||
},
|
||||
"predecessor": "nk48-r5-f3-implementation-r1",
|
||||
"role": "implementation continuation after independent r1 review; no claim of independence for this correction",
|
||||
"independent_review": "nk48-r5-f3-review-r1"
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"schema": "custodian.source-packet-manifest.v1",
|
||||
"files": [
|
||||
{
|
||||
"path": "README.md",
|
||||
"sha256": "168b80f6cc0d50650a484d24696022cac8a4cf8279c2fb4da0c112cfbf10fd08"
|
||||
},
|
||||
{
|
||||
"path": "candidate-validation.json",
|
||||
"sha256": "200132ab91899f6cfb2782b19ff59c72056f05197690cecdda708ccbdd343edc"
|
||||
},
|
||||
{
|
||||
"path": "focused-tests.json",
|
||||
"sha256": "2f48369c3d00f9a43989486882519ee5c09d4eb1f5be599a92b06ca0d7cc8581"
|
||||
},
|
||||
{
|
||||
"path": "focused-tests.txt",
|
||||
"sha256": "436459ced69db381f44a0f14d2ebcf00a4067f596a9881cfdcca7146c237d3a1"
|
||||
},
|
||||
{
|
||||
"path": "fresh-inverse-session.candidate.patch",
|
||||
"sha256": "0b4a466c23cdeeb34789ab79c3d7cdc4a186495a910e23bc6491a29b5ff9cff8"
|
||||
},
|
||||
{
|
||||
"path": "fresh-session-guard-omitted.txt",
|
||||
"sha256": "f47357c3303b026dcd9edcce564725a1c7d0d77969dd2857a6f1c008f169c1e1"
|
||||
},
|
||||
{
|
||||
"path": "implementation.json",
|
||||
"sha256": "8517713a130f23cb38c597d333f65ec27700f347c16dca617e988f1cbc948abf"
|
||||
},
|
||||
{
|
||||
"path": "negative-controls.json",
|
||||
"sha256": "8f96396f3d342f505d1dc6ac235a7f37a7c2481d51d100125f05b26619d3d314"
|
||||
},
|
||||
{
|
||||
"path": "owner-backend-next.md",
|
||||
"sha256": "6243d7a70868224cb89fd30818c23ac379f37b6576e4947e444ef5057d06aea8"
|
||||
},
|
||||
{
|
||||
"path": "owner-contract-candidate.md",
|
||||
"sha256": "4f9b91a2389acbc52558347a794da02faa6de4fb189373a11feeac10ae7e7c4f"
|
||||
},
|
||||
{
|
||||
"path": "owner-source-inventory.json",
|
||||
"sha256": "4510ce7b2f9eb9f4695ef4c57bba888c3839d3adcc397f4b4024be0fa71767bb"
|
||||
},
|
||||
{
|
||||
"path": "owner_configuration_contract.py",
|
||||
"sha256": "2cd04432cf3681afc126736f7daacfab46630441aeb223b3e222d6951d24ce2e"
|
||||
},
|
||||
{
|
||||
"path": "test_owner_configuration_contract.py",
|
||||
"sha256": "0f4246fea95bd3139c604da8712e6f6ac08976dd34d73e56ba83663ecba42761"
|
||||
},
|
||||
{
|
||||
"path": "type-owner-configuration-inverse.candidate.patch",
|
||||
"sha256": "949ef6d3e0acc574e1d69c251d6d09e3da636bc73d043dec1c5677f30de29947"
|
||||
},
|
||||
{
|
||||
"path": "type.before.yaml",
|
||||
"sha256": "e75fe94110ef9fb7fb18072fe2856941b33507c99cb406d36576046481485d0a"
|
||||
},
|
||||
{
|
||||
"path": "type.candidate.yaml",
|
||||
"sha256": "6e744be190c4f047e609bef7670ff987fe1606b4d7dac3f740f164bebc205a13"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"command": [
|
||||
"/home/worsch/ops-warden/.venv/bin/python",
|
||||
"-m",
|
||||
"pytest",
|
||||
"-q",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
"/tmp/nk48-apps-f3-r2-negative/test_owner_configuration_contract.py",
|
||||
"-k",
|
||||
"fresh_inverse_session or revoked_handover_session or missing_fresh_authority or inverse_scope_mismatches"
|
||||
],
|
||||
"exit_code": 1,
|
||||
"mutation": "omit mandatory inverse_session context only",
|
||||
"expected": "fresh session lifecycle and scope/refusal regressions detect mutation",
|
||||
"detected": true,
|
||||
"native_actions": false
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
# Actual Apps source and minimum T05 continuation
|
||||
|
||||
Apps HEAD remains `944b30ad63ab054cc24617e374e177d34cd572da`. Reviewed source is recorded by path/hash in `owner-source-inventory.json`; only source text was read. No referenced script was executed, no environment credential or home configuration was inspected and no native/network call was made.
|
||||
|
||||
## Existing surfaces
|
||||
|
||||
`Makefile:476` owns the attended Forgejo release path. It runs mailer/template prerequisite deployment targets, checks a Secret by name, then invokes normal Helm upgrade with chart/version, ordinary values, registry values and the existing value-bearing helper. It does not bind an accepted case, classify case-only configuration ownership, persist intent/post/inverse records, enforce all-release-writer exclusion or authenticate a separate inverse session. Running this entire target inside the new candidate would not prove that all its effects belong to the case. Its prerequisite applies also fall outside an OAuth-only inverse.
|
||||
|
||||
`tools/forgejo-verify.sh` is an older workload/TLS/database/mail/SSH/operator smoke script for another workplan's T05. It uses ambient operator contexts and optional credential sources and is not the four NK-WP-0048 login/group observation backend. It was read as source only and was not run. `tools/forgejo-smoke.sh` checks web/registry reachability, not member/non-member/group transitions. Neither is a hand-over receipt store or configuration inverse.
|
||||
|
||||
The actual owner review names the intended hand-over store as a committed `docs/evidence/<date>-forgejo-netkingdom-handover.json` identified by path@commit, with removal as a revert confirmed on main. This is a useful existing owner design to reuse where appropriate, not executable journal/authentication code. T05 still says implement the real owner_apply/owner_remove, verifier and receipt store; it does not provide their implementation. The current repo's executable source contains no implementation of the queried owner methods, transaction/inverse session or lanes. There is no supported existing backend to bind in this packet.
|
||||
|
||||
## Exact missing owner choices
|
||||
|
||||
1. Adopt/publish the corrected external wrapper plus independent inverse placement, replacing the earlier C3 placement inside owner_remove. Name the accepted configuration and helper source pins and the precise non-secret projection that proves this case's OAuth/group-reference change while preserving local sign-in and unrelated release configuration. The actual any-of access_groups behavior remains the owner T02/T03 verification duty; ordinary Helm revision advancement is not proof of case-only ownership.
|
||||
2. Name the existing owner-authenticated durable evidence mechanism and its write/readback semantics for pre-mutation intent, confirmed post state, inverse attempt, outcome and recovery. The existing committed-evidence design can be considered; this packet does not silently promote it into an implemented transactional configuration journal. Pending or publication ambiguity must survive process loss, and operation history must distinguish unrelated changes without storing Helm values, rendered Secrets or credentials. Loss after rollback but before inverse confirmation requires authenticated owner reconciliation, never unguarded replay.
|
||||
3. Identify the actual all-release-writer exclusion mechanism, or enforce the existing attended owner release-freeze/reconciliation path across current-revision/configuration check and rollback. It must cover every manual, automation and reconciliation writer. A local wrapper lock or pre-command revision equality is insufficient because Helm supplies no CAS. Source presently establishes no such enforcement.
|
||||
4. Bind `inverse_session` to the real attended owner runtime's current operation authorization and revocation evidence, using the exact original principal/owner/case/acceptance/source/cluster/namespace/release/client/descriptor/transaction scope and distinct inverse operation. Name how that exact scope/disposition is authenticated, retained and revoked on success/refusal/failure. The same operator can perform the fresh inverse; no standing agent credential is proposed. Existing valid scoped session authorization need not be requested again, but the revoked hand-over parent is unusable and a stored configuration receipt is not execution permission.
|
||||
|
||||
These are missing implementations/owner bindings inside existing T05, not new workflow/permission gates or a new service, store or authority created by this packet.
|
||||
|
||||
## Smallest actual source implementation next
|
||||
|
||||
Keep the current Apps attended deployment and owner-reviewed evidence location. Add one owner source module in its existing tools surface and a bounded Make entry which receives the exact accepted case/source/operation binding, invokes the selected durable/session/exclusion callbacks, performs only the accepted release change through the existing Helm deployment path, and invokes the unchanged admitted hand-over seam. Publish a separate inverse entry using that same journal and exact operation scope. An implementation must factor or explicitly exclude current unrelated deployment prerequisites; it cannot claim the whole target is an OAuth-only transaction.
|
||||
|
||||
Use the candidate protocol as the control-flow seam. Bind its callbacks only after the four choices above identify real owner facilities; do not ship an always-true authority oracle, local dictionary journal or advisory mutex as production enforcement. Then meaningful fake-command tests should cover argument/source pinning, metadata-only release readback, absent/matched/conflicting state, release-writer interference, pre/post/inverse persistence failures and revoked/mismatched session refusal before any command. Without those real facility choices, a command shim would falsely suggest operational completion, so none was implemented here.
|
||||
|
||||
Existing owner_apply/owner_remove must use completed configuration-inverse evidence and prove no remaining reference before deleting only authenticated case-created objects. The real four-probe observer and committed hand-over receipt/revert backend still belong to T05. T01 break-glass and attended T02/T03 acceptance remain the existing first-case work, not claims made by this source packet.
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# Consumer configuration transaction candidate for Apps T05
|
||||
|
||||
This is a coordinator-authored proposal for independent Apps review under RAPPS-WP-0016-T05, not Apps assent or an implemented owner capability. T02 already owns the Helm change and rollback to its noted previous revision. Keep that deployment mechanism and the seven kernel descriptors. Do not move Helm side effects into hand-over's verification, receipt callbacks, or Adapter.rollback. Do not add an eighth kernel step, a new service, a standing credential, or a new catalog grant.
|
||||
|
||||
The existing seven kernel adapters alone do not express this inverse: hand-over can authenticate only a newly created receipt, and confidential delivery can authenticate only its case-created objects. Public delivery is skipped; matched delivery returns NO_CHANGE. No such creation can grant authority over a Helm configuration. The reproducible direct-seam counterexample in the fixtures removes the public client's receipt and leaves the consumer configuration present. Merely adding an unused rollback helper would not fix it.
|
||||
|
||||
## Proposed reachable owner entry and inverse
|
||||
|
||||
The existing external owner deployment before hand-over becomes **one reviewed owner-run entry path**: begin configuration transaction, perform/confirm the T02 Helm change, then invoke the admitted hand-over step in the same attended owner session. `owner_configuration_contract.run` is an executable control-flow candidate for that path, exercised by the fixtures through the unchanged real hand-over adapter. It is not installed or callable by production. The owner must actually implement and publish its backend and bind its entry path under T05 before this text can establish readiness.
|
||||
|
||||
Before any mutation, bind an owner-authenticated durable intent to the exact case bytes/digest, case acceptance, owner, human principal, cluster identity, namespace/release, client, configuration source and reviewed wrapper/helper source digests, hand-over descriptor, and distinct owner operation/attempt ID. The wrapper also compares the case/acceptance/principal/owner/client/descriptor against the exact admitted hand-over Invocation. Bind the selected configuration to the case (issuer/client/groups and the confidential reference where applicable), not just to an arbitrary revision. Never store Helm values, rendered Secrets, login cookies or credential material in this record. Full values/chart material may be sensitive: retain only the owner-reviewed non-secret projection/digests and provenance references needed for the inverse. The real owner journal must authenticate the entire record; the fixture's dictionary and literal source pin provide no durable/native proof.
|
||||
|
||||
Under the owner's exclusive release-writer boundary, observe the before revision and its configuration identity, persist intent, perform the source-reviewed deployment, and persist authenticated post revision/configuration identity and a changed/no-change classification **before hand-over verification or receipt publication**. Confirm the operation provenance and that the allowed change was exactly this case's new OIDC configuration; do not infer ownership merely because a revision incremented. A deployment affecting unrelated chart/configuration changes is outside this inverse. Match an identical preexisting configuration as NO_CHANGE and give it no rollback authority. Refuse a conflicting preexisting source rather than overwrite it. Preserve local sign-in, unrelated configuration, data and preexisting resources.
|
||||
|
||||
`owner_configuration_contract.inverse` is an owner-run unwind entry on the same durable transaction, independent of secret-delivery outcomes and hand-over receipt creation. It is reached (a) by the wrapper on verification, receipt-recording or publication failure after confirmed configuration and (b) by the owner case-unwind path after a successful hand-over, before removing any delivery object. It applies to public and confidential cases. A failed hand-over is not an excuse to wait for a successful kernel receipt before invoking the owner inverse. A configuration NO_CHANGE is preserved; no Helm action is authorized. An authenticated, already completed inverse is idempotent evidence, not permission to run Helm again.
|
||||
|
||||
Every configuration inverse requires a distinct fresh authorized attended owner operation. `owner.inverse_session(binding, transaction)` must authenticate operation `configuration-inverse`, the exact original principal, owner, case/acceptance, configuration/helper source, cluster/namespace/release, client, descriptor and transaction scope against current disposition before entering the release-writer boundary. Its context must durably retain the inverse attempt authority/outcome and revoke its own session on every exit, including refused readback, rollback and persistence failures. The hand-over adapter revokes its own parent session on both success and failure before this inverse can run: never reuse that revoked session. Historical transaction provenance does not supply fresh execution authority. A same-principal fresh owner operation can use already established explicit scoped session authorization where valid; no new standing grant or additional workflow is proposed. If no current authorized attended inverse operation can be established, retain the confirmed/pending transaction and original hand-over error for owner reconciliation rather than mutate. The fixture's synthetic context does not authenticate any real principal or grant native authority.
|
||||
|
||||
For a changed configuration, authenticate the transaction and exact scope, require the current release still equals the recorded case-created post revision/configuration, and hold the owner's release-writer exclusion continuously across that check and the normal Helm rollback to the noted before revision. Confirm a new post-rollback revision restoring the exact approved before configuration; retain inverse evidence and transaction history. Current revision or configuration drift stops the inverse without touching other work. Helm's rollback command is not a revision CAS: the owner must identify a real exclusion mechanism covering *all* writers to this release, including manual and reconciliation paths, or retain an attended per-release freeze/owner-reconciliation gate. A local mutex that other writers ignore is insufficient. No such real exclusion is supplied by this packet or the existing forgejo-deploy Make target.
|
||||
|
||||
An intent/configuration/post-record persistence gap yields an ambiguous configuration outcome: keep the durable pending intent and stop for owner reconciliation using the exact operation/history/source binding. Do not replay, assert NO_CHANGE, or infer mutation ownership from an untrusted local record. Process loss does not confer an automatic inverse; T05 must supply authenticated durable readback/reconciliation before resuming. The fixture demonstrates this refusal; it does not implement the real journal/recovery backend. Failed inverse remains pending for owner reconciliation and retains the original hand-over failure as context.
|
||||
|
||||
The adapter's rollback remains exactly receipt-only and created-only. The independent configuration inverse must not fabricate or extend its `allowed_created`. If publication failed after receipt creation, configuration can be restored by its separately authenticated transaction while that receipt remains uncertain for Apps reconciliation; never report whole-case rollback complete from the configuration inverse alone.
|
||||
|
||||
For delivery cleanup, `owner_remove` must first verify the authenticated configuration inverse/no remaining consumer reference; it may reuse the completed inverse evidence, not run a second unguarded Helm rollback. It then removes only its authenticated case-created UIDs, in the existing reverse order, and reports `consumer_reference_reverted=true` only when actually proven. Preserve all preexisting-matched objects. If configuration itself was preexisting and still references a delivery object, NO_CHANGE is not proof of deactivation: refuse removing the referenced object until the owner resolves that dependency. Role/store deletion alone remains no token-revocation proof.
|
||||
|
||||
## Exact owner choice still required
|
||||
|
||||
Apps must adopt this external wrapper/transaction entry and inverse in T05, name the real authenticated journal, accepted configuration/source binding, release identity and writer-exclusion mechanism, and re-review the type's revised C3 wording. Its previous C3 expressly placed the Helm inverse inside owner_remove; this proposal changes that placement and cannot be treated as prior assent. The existing Makefile invokes normal Helm upgrade with no transaction journal or all-writer revision guard. T02's operational rollback intention is an existing legitimate mechanism, not an implemented proof boundary.
|
||||
|
||||
If Apps does not adopt an owner-run external transaction around the existing pre-hand-over deployment, there is no reachable complete inverse in the current seven-step contract. Then the owners must choose a structural execution/receipt contract change before accepting this type; do not silently expand either Adapter's object scope. The seven descriptors and their pins remain untouched in this proposal. Owner backend implementation, independent review, exact source publication, kernel/catalog acceptance and separately authorized native proof all remain outstanding.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"apps_head": "944b30ad63ab054cc24617e374e177d34cd572da",
|
||||
"inspection": "source-only; no native/backend/credential call",
|
||||
"files": [
|
||||
{
|
||||
"path": "AGENTS.md",
|
||||
"sha256": "2a366a5fbb69ba235df97ee117153cbfa1af11ab579372751b4fb5dbb30c31a7"
|
||||
},
|
||||
{
|
||||
"path": "INTENT.md",
|
||||
"sha256": "31060dc1738aa4540f78735019359a276ece39dad8afbf1be740713bebbe65e5"
|
||||
},
|
||||
{
|
||||
"path": "Makefile",
|
||||
"sha256": "a319e264e6f04378f6b3d2c7781860fc4bac1abdb248cffa8c26065e08f2f290"
|
||||
},
|
||||
{
|
||||
"path": "tools/forgejo-verify.sh",
|
||||
"sha256": "799571da79db58be2e6eee533ec20765aa9961bb74ea3865d69f48273a86f242"
|
||||
},
|
||||
{
|
||||
"path": "tools/forgejo-smoke.sh",
|
||||
"sha256": "f6146412cbe29f4abba9d81b3bc7a0652cf281b30a56fd7054e72f1574553869"
|
||||
},
|
||||
{
|
||||
"path": "workplans/RAPPS-WP-0016-forgejo-netkingdom-login.md",
|
||||
"sha256": "1077e9de358433bf1316e3db1459dd271f82875b9590bf3d7b5b22c0df42bbda"
|
||||
},
|
||||
{
|
||||
"path": "docs/proposals/2026-10-11-forgejo-consumer-eso/owner-design.md",
|
||||
"sha256": "c523949454e4c6eb58e8caee382380a561f561704136de62bf5783c592f962f2"
|
||||
},
|
||||
{
|
||||
"path": "docs/evidence/nk48-t16-owner-review/owner-review-railiance-apps.json",
|
||||
"sha256": "67e9ded8d37462ba04a0a224bac03a840d0c94326235474e1d3371c8ff61865d"
|
||||
}
|
||||
],
|
||||
"executable_surface_search": {
|
||||
"command": [
|
||||
"rg",
|
||||
"-n",
|
||||
"configuration.transaction|inverse_session|owner_configuration|consumer-secret-delivery\\.v1|def owner_apply|def owner_remove|handover-receipt|apps-consumer-handover|consumer-owner-deployment",
|
||||
"--glob",
|
||||
"!docs/**",
|
||||
"--glob",
|
||||
"!workplans/**",
|
||||
"--glob",
|
||||
"!wiki/**",
|
||||
"--glob",
|
||||
"!*.lock",
|
||||
"."
|
||||
],
|
||||
"cwd": "/home/worsch/railiance-apps",
|
||||
"exit_code": 1,
|
||||
"stdout": "",
|
||||
"stderr": ""
|
||||
},
|
||||
"supported_config_inverse_backend_found": false,
|
||||
"real_binding_implemented": false,
|
||||
"missing_choices": "owner-backend-next.md",
|
||||
"existing_task": "RAPPS-WP-0016-T05"
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""Unadopted Apps T05 control-flow candidate; no CLI, Helm or native capability.
|
||||
|
||||
The owner backend is mandatory, including durable authenticated records and an
|
||||
exclusive release-writer boundary. Tests supply a deliberately synthetic backend.
|
||||
This wraps the existing owner deployment + hand-over entry path, not an eighth
|
||||
kernel step or a replacement Adapter. No execution authority is provided here.
|
||||
"""
|
||||
from warden.case_run import Invocation
|
||||
from warden.case_receipt_state import Refused
|
||||
|
||||
|
||||
def inverse(binding, transaction, owner):
|
||||
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
|
||||
# This is a distinct operation: hand-over has already revoked its session.
|
||||
# Backend must authenticate a fresh attended inverse attempt, exact operation,
|
||||
# principal/owner/case/source/release/transaction scope and current disposition;
|
||||
# durably retain authority + outcome and revoke this attempt on every exit.
|
||||
# Durable configuration provenance alone is never execution authority.
|
||||
with owner.inverse_session(binding, transaction), owner.exclusive(binding):
|
||||
record = owner.authenticated_record(transaction)
|
||||
if record['binding'] != binding:
|
||||
raise Refused('configuration transaction binding differs')
|
||||
if record['state'] == 'restored':
|
||||
return 'already-restored'
|
||||
if record['state'] != 'confirmed':
|
||||
raise Refused('configuration outcome requires owner reconciliation')
|
||||
if not record['changed']:
|
||||
return 'preexisting-configuration-preserved'
|
||||
# The owner must serialize ALL release writers across this read + inverse.
|
||||
# Checking revision before an unguarded Helm command would leave a race.
|
||||
if owner.observe(binding) != record['after']:
|
||||
raise Refused('current release differs from case-created post revision')
|
||||
restored = owner.rollback_to(binding, record['before']['revision'])
|
||||
if (restored['configuration'] != record['before']['configuration']
|
||||
or restored['revision'] <= record['after']['revision']):
|
||||
raise Refused('owner Helm inverse readback differs')
|
||||
owner.confirm_inverse(transaction, restored)
|
||||
return 'case-configuration-restored'
|
||||
|
||||
|
||||
def run(binding, owner, admitted, handover):
|
||||
"""One owner entry: record/configure/confirm, then invoke real hand-over.
|
||||
|
||||
The owner must persist begin BEFORE configure and confirm BEFORE hand-over.
|
||||
Configuration/publication ambiguity stays pending for owner reconciliation.
|
||||
A failure after confirmed configuration reaches the same owner inverse even
|
||||
when verification fails before a receipt exists. A successful call returns a
|
||||
durable transaction identifier for a subsequent owner-run inverse request.
|
||||
"""
|
||||
if (type(admitted) is not Invocation or admitted.operation != 'admission'
|
||||
or admitted.attempt_id or admitted.step != 'hand-over'
|
||||
or admitted.actor != 'human' or admitted.lane != 'apps-consumer-handover'):
|
||||
raise Refused('owner wrapper requires exact hand-over admission')
|
||||
expected = {'case': admitted.pins.case, 'acceptance': admitted.pins.acceptance,
|
||||
'principal': admitted.principal, 'owner': admitted.owner,
|
||||
'client': admitted.target, 'handover_descriptor': admitted.pins.adapter}
|
||||
if any(binding.get(key) != value for key, value in expected.items()):
|
||||
raise Refused('owner wrapper differs from hand-over admission')
|
||||
with owner.exclusive(binding):
|
||||
before = owner.observe(binding)
|
||||
transaction = owner.begin(binding, before)
|
||||
# begin/configure/confirm exceptions deliberately do not infer rollback
|
||||
# authority: a mutation may have occurred before its proof was committed.
|
||||
after, changed = owner.configure(binding, before)
|
||||
owner.confirm(transaction, after, changed)
|
||||
try:
|
||||
result = handover(admitted)
|
||||
except BaseException:
|
||||
# Failure to unwind propagates with the original error as its context.
|
||||
inverse(binding, transaction, owner)
|
||||
raise
|
||||
return transaction, result
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
"""Synthetic owner flow through the unchanged frozen hand-over/delivery seams.
|
||||
|
||||
No owner approval, trusted journal, real Helm, kernel admission or native proof.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict, replace
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
import yaml
|
||||
from warden.case_run import Invocation, digest
|
||||
from warden.case_receipt_state import Outcome, Pins, Refused
|
||||
from owner_configuration_contract import run, inverse
|
||||
|
||||
ROOT = Path(os.environ['NK_FROZEN_ROOT'])
|
||||
ADAPTERS = ROOT / 'sso-mfa/k8s/keycape'
|
||||
def load(name, filename):
|
||||
spec = importlib.util.spec_from_file_location(name, ADAPTERS / filename)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
h = load('f3_frozen_handover', 'handover-adapter.py')
|
||||
d = load('f3_frozen_delivery', 'deliver-consumer-secret-adapter.py')
|
||||
|
||||
|
||||
class SyntheticOwner:
|
||||
"""In-memory stand-in for the *required*, currently absent Apps T05 backend."""
|
||||
def __init__(self, binding, desired):
|
||||
self.binding, self.desired = deepcopy(binding), desired
|
||||
self.current = {'revision': 7, 'configuration': {'local_signin': True, 'oidc': None}}
|
||||
self.history = {7: deepcopy(self.current['configuration'])}
|
||||
self.records, self.calls = {}, []
|
||||
self.exclusive_available = True
|
||||
self.locked = False
|
||||
self.fail_confirm = self.fail_inverse = False
|
||||
# Explicitly synthetic authority oracle. No real principal or grant.
|
||||
self.inverse_authority_available = True
|
||||
self.inverse_authority_override = None
|
||||
self.inverse_session_id_override = None
|
||||
self.inverse_session_events = []
|
||||
self.revoked_sessions = set()
|
||||
self.inverse_session_open = False
|
||||
self.inverse_attempt_count = 0
|
||||
|
||||
@contextmanager
|
||||
def inverse_session(self, binding, transaction):
|
||||
requested = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(binding), 'transaction': transaction}
|
||||
authorized = (deepcopy(self.inverse_authority_override)
|
||||
if self.inverse_authority_override is not None else requested)
|
||||
if (not self.inverse_authority_available or authorized != requested
|
||||
or binding != self.binding or transaction not in self.records):
|
||||
raise Refused('fresh attended inverse scope/authority absent')
|
||||
self.inverse_attempt_count += 1
|
||||
session = (self.inverse_session_id_override
|
||||
or 'fixture-inverse:' + str(self.inverse_attempt_count))
|
||||
if session in self.revoked_sessions:
|
||||
raise Refused('inverse cannot reuse a revoked session')
|
||||
assert not self.inverse_session_open
|
||||
self.inverse_session_open = True
|
||||
self.inverse_session_events.append(('open', session, deepcopy(requested)))
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
self.inverse_session_open = False
|
||||
self.revoked_sessions.add(session)
|
||||
self.inverse_session_events.append(('revoke', session))
|
||||
|
||||
@contextmanager
|
||||
def exclusive(self, binding):
|
||||
if not self.exclusive_available or binding != self.binding:
|
||||
raise Refused('owner release-writer boundary or source/principal binding absent')
|
||||
assert not self.locked
|
||||
self.locked = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.locked = False
|
||||
|
||||
def observe(self, binding):
|
||||
assert self.locked and binding == self.binding
|
||||
return deepcopy(self.current)
|
||||
|
||||
def begin(self, binding, before):
|
||||
assert self.locked
|
||||
ident = 'fixture-transaction:' + str(len(self.records))
|
||||
self.records[ident] = {'binding': deepcopy(binding), 'before': deepcopy(before), 'state': 'pending'}
|
||||
self.calls.append('durable-intent')
|
||||
return ident
|
||||
|
||||
def configure(self, binding, before):
|
||||
assert self.locked and binding == self.binding and before == self.current
|
||||
if self.current['configuration']['oidc'] == self.desired:
|
||||
self.calls.append('matched-configuration')
|
||||
return deepcopy(self.current), False
|
||||
if self.current['configuration']['oidc'] is not None:
|
||||
raise Refused('preexisting configuration conflicts')
|
||||
self.current['revision'] += 1
|
||||
self.current['configuration']['oidc'] = self.desired
|
||||
self.history[self.current['revision']] = deepcopy(self.current['configuration'])
|
||||
self.calls.append('helm-configure')
|
||||
return deepcopy(self.current), True
|
||||
|
||||
def confirm(self, ident, after, changed):
|
||||
assert self.locked
|
||||
if self.fail_confirm:
|
||||
raise Refused('configuration receipt persistence failed')
|
||||
self.records[ident].update(after=deepcopy(after), changed=changed, state='confirmed')
|
||||
self.calls.append('durable-post-revision')
|
||||
|
||||
def authenticated_record(self, ident):
|
||||
assert self.locked
|
||||
if ident not in self.records:
|
||||
raise Refused('owner transaction not authenticated')
|
||||
return deepcopy(self.records[ident])
|
||||
|
||||
def rollback_to(self, binding, revision):
|
||||
assert self.locked and binding == self.binding
|
||||
self.calls.append(('helm-rollback', revision))
|
||||
if self.fail_inverse:
|
||||
raise Refused('owner rollback failed')
|
||||
self.current = {'revision': self.current['revision'] + 1,
|
||||
'configuration': deepcopy(self.history[revision])}
|
||||
return deepcopy(self.current)
|
||||
|
||||
def confirm_inverse(self, ident, restored):
|
||||
assert self.locked
|
||||
self.records[ident].update(state='restored', inverse=deepcopy(restored))
|
||||
self.calls.append('durable-inverse')
|
||||
|
||||
|
||||
class OwnerFlowTests(unittest.TestCase):
|
||||
def setup_flow(self, confidential=False):
|
||||
name = 'forgejo.yaml' if confidential else 'example-public-portal.yaml'
|
||||
source = (ROOT / 'security-case-type-revisions/1.3/oidc-browser-relying-party/examples' / name).read_bytes()
|
||||
self.case = source
|
||||
params = yaml.safe_load(source)['spec']['parameters']
|
||||
self.params = params
|
||||
self.owner_repo, self.client = params['consumer_owner_repo'], params['client_id']
|
||||
desc = h.descriptor(self.owner_repo)
|
||||
encoded = json.dumps(asdict(desc), sort_keys=True, separators=(',', ':')).encode()
|
||||
pins = Pins(digest(source), digest(b'fixture-type'), digest(encoded), digest(b'fixture-catalog'),
|
||||
digest(b'fixture-acceptance'), digest(b'fixture-run-sheet'))
|
||||
self.admitted = Invocation('hand-over', self.owner_repo, self.client, 'human', 'fixture-founder',
|
||||
'apps-consumer-handover', pins)
|
||||
self.inv = replace(self.admitted, attempt_id='fixture-run', operation='run')
|
||||
self.rb = replace(self.admitted, attempt_id='fixture-rollback', operation='rollback')
|
||||
self.binding = {'case': pins.case, 'acceptance': pins.acceptance, 'principal': self.admitted.principal,
|
||||
'owner': self.owner_repo, 'source': digest(b'fixture-approved-owner-config-source'),
|
||||
'cluster': 'fixture-cluster-uid', 'namespace': params['consumer_namespace'],
|
||||
'release': params['consuming_service'], 'client': self.client,
|
||||
'handover_descriptor': pins.adapter, 'attempt': 'fixture-owner-attempt'}
|
||||
desired = {'issuer': params.get('issuer', 'https://fixture-issuer.invalid'), 'client': self.client,
|
||||
'groups': params['access_groups']}
|
||||
self.owner = SyntheticOwner(self.binding, desired)
|
||||
self.receipts, self.evidence, self.revoked = set(), {}, []
|
||||
self.fail_verify = self.fail_record = self.fail_publish = False
|
||||
def verify(client, groups):
|
||||
self.owner.calls.append('handover-verification')
|
||||
return {'client_id': client, 'groups': list(groups), 'enforces_groups': True,
|
||||
'login_verified': not self.fail_verify,
|
||||
'observations': [{'probe': p, 'evidence': 'fixture-probe:' + p} for p in sorted(h.PROBES)]}
|
||||
def record(client, observed):
|
||||
self.owner.calls.append('handover-receipt')
|
||||
if self.fail_record:
|
||||
raise Refused('fixture receipt failure')
|
||||
ref = 'fixture-handover:' + client
|
||||
created = ref not in self.receipts
|
||||
self.receipts.add(ref)
|
||||
return ref, created
|
||||
def publish(payload):
|
||||
if self.fail_publish:
|
||||
raise Refused('fixture publication failed')
|
||||
ident = 'fixture-evidence:' + str(len(self.evidence))
|
||||
self.evidence[ident] = payload
|
||||
return ident
|
||||
def remove(ref):
|
||||
self.receipts.remove(ref)
|
||||
return True
|
||||
def revoke(inv):
|
||||
self.revoked.append(inv)
|
||||
self.owner.revoked_sessions.add(inv.attempt_id)
|
||||
return 'fixture-revoked:' + inv.attempt_id
|
||||
self.adapter = h.build(admitted=self.admitted, case_source=source, case_acceptance='fixture-acceptance',
|
||||
verify_consumer=verify, record_handover=record, remove_handover=remove, publish=publish,
|
||||
authenticate=lambda ref, payload: self.evidence.get(ref) == payload,
|
||||
revoke_session=revoke, version_root='1.3')
|
||||
|
||||
def flow(self):
|
||||
# Reachable candidate owner entry: configuration encloses the REAL seam.
|
||||
return run(self.binding, self.owner, self.admitted,
|
||||
lambda admitted: self.adapter.invoke(replace(admitted, attempt_id=self.inv.attempt_id, operation="run")))
|
||||
|
||||
def delivery_no_change(self):
|
||||
encoded = json.dumps(asdict(d.descriptor(self.owner_repo)), sort_keys=True, separators=(',', ':')).encode()
|
||||
admitted = replace(self.admitted, step='deliver-consumer-secret', lane='consumer-owner-deployment',
|
||||
pins=replace(self.admitted.pins, adapter=digest(encoded)))
|
||||
self.delivery_removals = []
|
||||
evidence = {}
|
||||
def apply(inv, want):
|
||||
return {'schema': 'netkingdom.consumer-secret-delivery.v1', 'operation': 'apply',
|
||||
'values_emitted': False, 'consumer_reference_reverted': False,
|
||||
'objects': {name: {'uid': 'preexisting-' + name, 'created': False, 'metadata': metadata}
|
||||
for name, metadata in want.items()}}
|
||||
def remove(inv, created):
|
||||
self.delivery_removals.append(created)
|
||||
raise AssertionError('matched delivery cannot authorize removal')
|
||||
def publish(payload):
|
||||
ident = 'fixture-delivery:' + str(len(evidence)); evidence[ident] = payload; return ident
|
||||
adapter = d.build(admitted=admitted, case_source=self.case, owner_apply=apply, owner_remove=remove,
|
||||
publish=publish, authenticate=lambda ref, payload: evidence.get(ref) == payload,
|
||||
revoke_session=lambda inv: 'fixture-revoked:' + inv.attempt_id)
|
||||
result = adapter.invoke(replace(admitted, attempt_id='fixture-delivery', operation='run'))
|
||||
self.assertEqual((result.outcome, result.created), (Outcome.NO_CHANGE, ()))
|
||||
return adapter, replace(admitted, attempt_id='fixture-delivery-inverse', operation='rollback')
|
||||
|
||||
def test_existing_public_flow_counterexample_receipt_rollback_leaves_helm_change(self):
|
||||
self.setup_flow()
|
||||
self.assertEqual(self.params['client_type'], 'public')
|
||||
# Reproduce existing text: owner's own configuration, then receipt seam.
|
||||
self.owner.current['configuration']['oidc'] = self.owner.desired
|
||||
result = self.adapter.invoke(self.inv)
|
||||
self.adapter.rollback(self.rb, result.created)
|
||||
self.assertEqual(self.receipts, set())
|
||||
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
|
||||
|
||||
def test_candidate_public_flow_reaches_independent_inverse(self):
|
||||
self.setup_flow()
|
||||
transaction, result = self.flow()
|
||||
self.assertTrue(self.adapter.verify(self.inv, result))
|
||||
self.assertLess(self.owner.calls.index('durable-post-revision'), self.owner.calls.index('handover-verification'))
|
||||
self.adapter.rollback(self.rb, result.created) # unchanged receipt-only path
|
||||
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
|
||||
self.assertEqual(inverse(self.binding, transaction, self.owner), 'case-configuration-restored')
|
||||
self.assertEqual(self.owner.current['configuration'], {'local_signin': True, 'oidc': None})
|
||||
self.assertEqual(inverse(self.binding, transaction, self.owner), 'already-restored')
|
||||
|
||||
def test_confidential_no_change_delivery_cannot_block_configuration_inverse(self):
|
||||
self.setup_flow(confidential=True)
|
||||
adapter, rb = self.delivery_no_change()
|
||||
transaction, result = self.flow()
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
with self.assertRaises(Refused): # contained owner path sanitizes refusal text
|
||||
adapter.rollback(rb, ('consumer-externalsecret',))
|
||||
self.adapter.rollback(self.rb, result.created)
|
||||
self.assertEqual(self.delivery_removals, [])
|
||||
self.assertIsNone(self.owner.current['configuration']['oidc'])
|
||||
|
||||
def test_preexisting_configuration_and_receipt_preserved(self):
|
||||
self.setup_flow()
|
||||
self.owner.current['configuration']['oidc'] = self.owner.desired
|
||||
before = deepcopy(self.owner.current)
|
||||
self.receipts.add('fixture-handover:' + self.client)
|
||||
transaction, result = self.flow()
|
||||
self.assertEqual(result.outcome, Outcome.NO_CHANGE)
|
||||
self.assertEqual(inverse(self.binding, transaction, self.owner), 'preexisting-configuration-preserved')
|
||||
with self.assertRaises(Refused):
|
||||
self.adapter.rollback(self.rb, (h.RECEIPT,))
|
||||
self.assertEqual(self.owner.current, before)
|
||||
self.assertEqual(len(self.receipts), 1)
|
||||
self.assertFalse(any(isinstance(c, tuple) for c in self.owner.calls))
|
||||
|
||||
def test_changed_revision_refuses_inverse_and_preserves_other_work(self):
|
||||
self.setup_flow()
|
||||
transaction, _ = self.flow()
|
||||
self.owner.current['revision'] += 1
|
||||
self.owner.current['configuration']['other_work'] = True
|
||||
before = deepcopy(self.owner.current)
|
||||
with self.assertRaisesRegex(Refused, 'current release differs'):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
self.assertEqual(self.owner.current, before)
|
||||
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
|
||||
|
||||
def test_failure_before_receipt_still_reaches_inverse(self):
|
||||
for failure in ('fail_verify', 'fail_record'):
|
||||
with self.subTest(failure=failure):
|
||||
self.setup_flow(); setattr(self, failure, True)
|
||||
with self.assertRaises(Refused): self.flow()
|
||||
self.assertEqual(self.receipts, set())
|
||||
self.assertIsNone(self.owner.current['configuration']['oidc'])
|
||||
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'restored')
|
||||
self.assertEqual(self.revoked, [self.inv])
|
||||
|
||||
def test_receipt_publication_failure_restores_config_without_claiming_receipt_removal(self):
|
||||
self.setup_flow(); self.fail_publish = True
|
||||
with self.assertRaises(Refused): self.flow()
|
||||
self.assertIsNone(self.owner.current['configuration']['oidc'])
|
||||
self.assertEqual(len(self.receipts), 1) # uncertain receipt must be reconciled
|
||||
with self.assertRaises(Refused): self.adapter.rollback(self.rb, (h.RECEIPT,))
|
||||
|
||||
def test_configuration_confirmation_failure_requires_owner_reconciliation(self):
|
||||
self.setup_flow(); self.owner.fail_confirm = True
|
||||
with self.assertRaises(Refused): self.flow()
|
||||
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'pending')
|
||||
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
|
||||
self.assertNotIn('handover-verification', self.owner.calls)
|
||||
with self.assertRaisesRegex(Refused, 'requires owner reconciliation'):
|
||||
inverse(self.binding, 'fixture-transaction:0', self.owner)
|
||||
|
||||
def test_exact_binding_mismatches_refused(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
before = deepcopy(self.owner.current)
|
||||
for field in self.binding:
|
||||
with self.subTest(field=field), self.assertRaises(Refused):
|
||||
inverse({**self.binding, field: 'different'}, transaction, self.owner)
|
||||
self.assertEqual(self.owner.current, before)
|
||||
|
||||
def test_wrapper_admission_binding_refused_before_owner_mutation(self):
|
||||
self.setup_flow()
|
||||
for field, value in [('principal', 'other'), ('target', 'other'), ('owner', 'other'),
|
||||
('actor', 'agent'), ('lane', 'other')]:
|
||||
with self.subTest(field=field), self.assertRaises(Refused):
|
||||
run(self.binding, self.owner, replace(self.admitted, **{field: value}),
|
||||
lambda admitted: self.fail('must not reach hand-over'))
|
||||
self.assertEqual(self.owner.records, {})
|
||||
|
||||
def test_missing_release_writer_guard_refuses_before_configuration(self):
|
||||
self.setup_flow(); self.owner.exclusive_available = False
|
||||
with self.assertRaises(Refused): self.flow()
|
||||
self.assertEqual(self.owner.records, {})
|
||||
self.assertIsNone(self.owner.current['configuration']['oidc'])
|
||||
|
||||
def test_preexisting_conflicting_configuration_not_overwritten(self):
|
||||
self.setup_flow(); self.owner.current['configuration']['oidc'] = {'client': 'unrelated'}
|
||||
before = deepcopy(self.owner.current)
|
||||
with self.assertRaisesRegex(Refused, 'configuration conflicts'): self.flow()
|
||||
self.assertEqual(self.owner.current, before)
|
||||
self.assertNotIn('handover-verification', self.owner.calls)
|
||||
|
||||
def test_inverse_failure_retains_original_failure_and_pending_unwind(self):
|
||||
self.setup_flow(); self.fail_verify = True; self.owner.fail_inverse = True
|
||||
with self.assertRaisesRegex(Refused, 'owner rollback failed') as caught:
|
||||
self.flow()
|
||||
self.assertIsInstance(caught.exception.__context__, Refused)
|
||||
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'confirmed')
|
||||
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
|
||||
|
||||
def test_fresh_inverse_session_exact_scope_open_and_revoked(self):
|
||||
self.setup_flow()
|
||||
transaction, _ = self.flow()
|
||||
self.assertEqual(self.revoked, [self.inv])
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
opened, closed = self.owner.inverse_session_events
|
||||
self.assertEqual(opened[0], 'open')
|
||||
self.assertNotEqual(opened[1], self.inv.attempt_id)
|
||||
self.assertEqual(opened[2], {'operation': 'configuration-inverse',
|
||||
'binding': self.binding, 'transaction': transaction})
|
||||
self.assertEqual(closed, ('revoke', opened[1]))
|
||||
self.assertIn(opened[1], self.owner.revoked_sessions)
|
||||
self.assertFalse(self.owner.inverse_session_open)
|
||||
|
||||
def test_revoked_handover_session_refused_for_later_inverse(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
self.owner.inverse_session_id_override = self.inv.attempt_id
|
||||
before = deepcopy(self.owner.current)
|
||||
with self.assertRaisesRegex(Refused, 'revoked session'):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
self.assertEqual(self.owner.current, before)
|
||||
self.assertEqual(self.owner.inverse_session_events, [])
|
||||
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
|
||||
|
||||
def test_missing_fresh_authority_failure_unwind_retains_original_error(self):
|
||||
self.setup_flow(); self.fail_verify = True
|
||||
self.owner.inverse_authority_available = False
|
||||
with self.assertRaisesRegex(Refused, 'fresh attended') as caught:
|
||||
self.flow()
|
||||
self.assertEqual(self.revoked, [self.inv])
|
||||
self.assertIsInstance(caught.exception.__context__, Refused)
|
||||
self.assertIn('consumer verification', str(caught.exception.__context__))
|
||||
self.assertEqual(self.owner.current['configuration']['oidc'], self.owner.desired)
|
||||
self.assertEqual(next(iter(self.owner.records.values()))['state'], 'confirmed')
|
||||
self.assertEqual(self.owner.inverse_session_events, [])
|
||||
|
||||
def test_inverse_scope_mismatches_refused_without_mutation(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
scope = {'operation': 'configuration-inverse',
|
||||
'binding': deepcopy(self.binding), 'transaction': transaction}
|
||||
before = deepcopy(self.owner.current)
|
||||
cases = [{'operation': 'hand-over'}, {'transaction': 'other-transaction'}]
|
||||
cases += [{'binding': {**self.binding, field: 'other'}} for field in self.binding]
|
||||
for changed in cases:
|
||||
with self.subTest(changed=changed):
|
||||
self.owner.inverse_authority_override = {**scope, **changed}
|
||||
with self.assertRaisesRegex(Refused, 'scope/authority'):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
self.assertEqual(self.owner.current, before)
|
||||
self.assertEqual(self.owner.inverse_session_events, [])
|
||||
|
||||
def test_fresh_inverse_session_revoked_on_inverse_failure(self):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
self.owner.fail_inverse = True
|
||||
with self.assertRaisesRegex(Refused, 'owner rollback failed'):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
opened, closed = self.owner.inverse_session_events
|
||||
self.assertEqual(closed, ('revoke', opened[1]))
|
||||
self.assertFalse(self.owner.inverse_session_open)
|
||||
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
|
||||
|
||||
def test_fresh_inverse_session_revoked_on_guard_and_persistence_failure(self):
|
||||
for failure in ('drift', 'persistence'):
|
||||
with self.subTest(failure=failure):
|
||||
self.setup_flow(); transaction, _ = self.flow()
|
||||
if failure == 'drift':
|
||||
self.owner.current['revision'] += 1
|
||||
else:
|
||||
def refuse(ident, restored):
|
||||
raise Refused('inverse persistence failed')
|
||||
self.owner.confirm_inverse = refuse
|
||||
with self.assertRaises(Refused):
|
||||
inverse(self.binding, transaction, self.owner)
|
||||
opened, closed = self.owner.inverse_session_events
|
||||
self.assertEqual(closed, ('revoke', opened[1]))
|
||||
self.assertFalse(self.owner.inverse_session_open)
|
||||
self.assertEqual(self.owner.records[transaction]['state'], 'confirmed')
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
--- a/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml
|
||||
+++ b/security-case-type-revisions/1.3/oidc-browser-relying-party/type.yaml
|
||||
@@ -355,16 +355,30 @@
|
||||
Secret values.
|
||||
- id: hand-over
|
||||
owner: '{consumer_owner_repo}'
|
||||
- entry_point: The consumer owner first makes its own Helm change configuring {consuming_service}
|
||||
- with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id},
|
||||
- requiring one of {access_groups} in the groups claim before sign-in completes.
|
||||
- Then reviewed adapter net-kingdom/keycape-hand-over, dispatched by Warden's case
|
||||
- kernel inside the attended consumer-owner session, verifies the consumer with
|
||||
- four value-free probes (member-login, non-member-refused, membership-add-grants,
|
||||
- membership-remove-revokes) and records the hand-over receipt.
|
||||
+ entry_point: The consumer owner's reviewed deployment entry wraps its own Helm
|
||||
+ configuration and the admitted hand-over in one owner configuration transaction,
|
||||
+ for public clients as well as confidential clients even when delivery is NO_CHANGE.
|
||||
+ Before changing {consuming_service}, durably bind exact case, acceptance, human
|
||||
+ principal, owner configuration source, cluster/release and before revision;
|
||||
+ authenticate the case-created post revision under the owner's release-writer
|
||||
+ exclusion before verification. Configure issuer {issuer}, client {client_id}
|
||||
+ and, only for confidential clients, custody bao:platform/workloads/keycape/{client_id},
|
||||
+ requiring one of {access_groups} before sign-in completes. Preserve identical
|
||||
+ preexisting configuration as NO_CHANGE and refuse conflicts. Then the reviewed
|
||||
+ adapter net-kingdom/keycape-hand-over, dispatched by Warden's case kernel inside
|
||||
+ the attended consumer-owner session, verifies the four value-free probes
|
||||
+ (member-login, non-member-refused, membership-add-grants, membership-remove-revokes)
|
||||
+ and records the receipt. The owner wrapper's separate configuration inverse is
|
||||
+ reachable on failure before a receipt exists and on later owner case unwind;
|
||||
+ it does not depend on delivery-created objects or receipt creation. Missing or
|
||||
+ ambiguous configuration provenance requires owner reconciliation, never replay.
|
||||
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
|
||||
parent-session revoked per attempt); the consumer configuration itself is the
|
||||
- owner's own deployment path
|
||||
+ owner's own deployment path. Every configuration inverse is a distinct fresh
|
||||
+ authorized attended owner operation, bound to operation, exact principal, case,
|
||||
+ source, release and transaction, with its own per-attempt revocation on every
|
||||
+ exit. Never reuse the already revoked hand-over parent session. Missing current
|
||||
+ inverse authority retains pending owner reconciliation evidence
|
||||
lane: apps-consumer-handover
|
||||
warden_executes: false
|
||||
attended: true
|
||||
@@ -430,13 +444,22 @@
|
||||
reads_secret_value: false
|
||||
rollback:
|
||||
- reverses: hand-over
|
||||
- action: the adapter removes only its hand-over receipt. Removing the OIDC source
|
||||
- for {client_id} from {consuming_service} is the owner's Helm rollback (owner
|
||||
- {consumer_owner_repo}), done inside deliver-consumer-secret's owner_remove
|
||||
- before any consumer object is removed
|
||||
+ action: the adapter removes only its authenticated case-created hand-over receipt.
|
||||
+ Independently, the consumer owner's configuration transaction inverse restores
|
||||
+ only this case's configuration change through the noted before Helm revision,
|
||||
+ after authenticating exact case/principal/source/release binding and checking
|
||||
+ the unchanged post revision under exclusion of all release writers. It runs
|
||||
+ on owner unwind for public and confidential cases, including skipped or NO_CHANGE
|
||||
+ delivery and failures before receipt creation. Preserve preexisting configuration;
|
||||
+ drift or ambiguous provenance requires owner reconciliation. This owner inverse
|
||||
+ is outside the receipt adapter and precedes any delivery-object removal;
|
||||
+ receipt removal alone never proves configuration rollback.
|
||||
- reverses: deliver-consumer-secret
|
||||
when: client_type == confidential
|
||||
- action: After reverting consumer secret reference, delete only authenticated created
|
||||
+ action: First verify the owner configuration transaction inverse and absence of
|
||||
+ remaining consumer secret references; NO_CHANGE configuration alone is not proof of
|
||||
+ deactivation. Do not repeat an unguarded Helm rollback inside owner_remove.
|
||||
+ Then delete only authenticated created
|
||||
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
|
||||
dedicated SA; preserve preexisting-matched resources and never delete a shared
|
||||
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
apiVersion: netkingdom.io/security-case-type/v0.1
|
||||
kind: SecurityCaseType
|
||||
spec:
|
||||
id: oidc-browser-relying-party
|
||||
version: '1.3'
|
||||
status: proposed
|
||||
title: Browser OIDC relying party for an internal service
|
||||
purpose: 'Let an internal web service sign its human users in through NetKingdom
|
||||
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups the
|
||||
service maps to its own roles, and, for confidential clients, a client secret
|
||||
minted straight into OpenBao custody. Replaces the per-client rollout scripts
|
||||
in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).'
|
||||
parameters:
|
||||
- name: client_id
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
|
||||
sensitivity: operational
|
||||
description: OAuth2 client_id, unique in KeyCape.
|
||||
- name: display_name
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[^\n\r]{1,80}$
|
||||
sensitivity: public
|
||||
description: Name shown on the sign-in page.
|
||||
- name: redirect_uris
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^https://[^*?#\s,]+(,https://[^*?#\s,]+)*$
|
||||
sensitivity: security_sensitive
|
||||
description: 'Comma-separated exact https redirect URIs. No wildcards, no plain
|
||||
http, no query and no fragment: KeyCape refuses ''*'' and ''?'' at startup,
|
||||
and OAuth 2.0 forbids fragments (key-cape type review R1).'
|
||||
- name: allowed_scopes
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- openid,profile,email,groups
|
||||
- openid,email,groups
|
||||
- openid,profile,groups
|
||||
- openid,groups
|
||||
sensitivity: security_sensitive
|
||||
description: Scopes the client may request, openid first. Always includes groups,
|
||||
because access_groups is required and KeyCape's UserInfo returns groups only
|
||||
for that scope (key-cape type review R2).
|
||||
- name: client_type
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- confidential
|
||||
- public
|
||||
sensitivity: security_sensitive
|
||||
description: confidential for server-side services that can hold a secret; public
|
||||
for SPAs and native apps (PKCE only).
|
||||
- name: mfa_required
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- 'true'
|
||||
- 'false'
|
||||
sensitivity: security_sensitive
|
||||
description: '"true" pins MFA on this client; required when the service grants
|
||||
administrative or owner rights. "false" is not "no MFA": the client then carries
|
||||
no mfaRequired field and KeyCape applies the per-user privacyIDEA policy. A
|
||||
renderer must never emit an explicit mfaRequired false, which would skip that
|
||||
policy (key-cape type review R3).'
|
||||
- name: access_groups
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
|
||||
sensitivity: operational
|
||||
description: 'Comma-separated LLDAP groups that the service maps to its roles.
|
||||
Created if absent. KeyCape has no per-client group gate: it issues tokens to
|
||||
any authenticated user and only carries the groups claim, so the consuming service
|
||||
enforces the gate itself (for Forgejo, its required-claim setting; key-cape
|
||||
type review R2).'
|
||||
- name: initial_members
|
||||
type: string
|
||||
required: false
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*)*$
|
||||
sensitivity: operational
|
||||
description: Initial memberships as group=user+user;group=user. Usernames are
|
||||
directory usernames, never secrets.
|
||||
- name: consuming_service
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||
sensitivity: operational
|
||||
description: The service that signs users in with this client.
|
||||
- name: consumer_owner_repo
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]*$
|
||||
sensitivity: operational
|
||||
description: The repository that owns the consuming service's configuration.
|
||||
- name: consumer_namespace
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
sensitivity: security_sensitive
|
||||
description: Consumer namespace for namespaced ESO TokenRequest reader; actual
|
||||
workload owner must confirm; never infer from service name.
|
||||
- name: issuer
|
||||
type: string
|
||||
case_settable: false
|
||||
default: https://kc.coulomb.social
|
||||
sensitivity: security_sensitive
|
||||
description: The NetKingdom issuer; fixed by the type.
|
||||
composition:
|
||||
authority: netkingdom
|
||||
initial_trust:
|
||||
- runtime_secret_trust
|
||||
- runtime_identity_trust
|
||||
capabilities:
|
||||
- c1.lightweight-sso
|
||||
providers:
|
||||
c1.lightweight-sso: net-kingdom.keycape-client-registration
|
||||
parameter_bindings:
|
||||
net-kingdom.keycape-client-registration.client_id: client_id
|
||||
net-kingdom.keycape-client-registration.display_name: display_name
|
||||
net-kingdom.keycape-client-registration.redirect_uris: redirect_uris
|
||||
net-kingdom.keycape-client-registration.allowed_scopes: allowed_scopes
|
||||
net-kingdom.keycape-client-registration.client_type: client_type
|
||||
net-kingdom.keycape-client-registration.mfa_required: mfa_required
|
||||
materialization:
|
||||
- id: client-secret-path
|
||||
kind: kv_path
|
||||
when: client_type == confidential
|
||||
template: platform/workloads/keycape/{client_id}
|
||||
body: field CLIENT_SECRET; minted by the credential broker, never written by hand
|
||||
- id: keycape-reader-policy
|
||||
kind: openbao_policy
|
||||
when: client_type == confidential
|
||||
template: workload-kv-read-keycape-{client_id}-eso
|
||||
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
|
||||
\ = [\"read\"]\n}}\n"
|
||||
description: KeyCape reads its client secret only through ESO. No metadata, list,
|
||||
parent or sibling access.
|
||||
- id: consumer-reader-policy
|
||||
kind: openbao_policy
|
||||
when: client_type == confidential
|
||||
template: workload-kv-read-keycape-{client_id}-{consuming_service}
|
||||
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
|
||||
\ = [\"read\"]\n}}\n"
|
||||
description: The consuming service reads the same secret. No metadata, list, parent
|
||||
or sibling access.
|
||||
- id: eso-role
|
||||
kind: openbao_role
|
||||
when: client_type == confidential
|
||||
template: external-secrets-keycape-{client_id}
|
||||
body: 'auth: kubernetes
|
||||
|
||||
bound_service_account_names: [external-secrets]
|
||||
|
||||
bound_service_account_namespaces: [external-secrets]
|
||||
|
||||
token_policies: [workload-kv-read-keycape-{client_id}-eso]
|
||||
|
||||
token_no_default_policy: true
|
||||
|
||||
token_ttl: 15m
|
||||
|
||||
token_max_ttl: 15m
|
||||
|
||||
'
|
||||
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service
|
||||
account only.
|
||||
- id: consumer-eso-role
|
||||
kind: openbao_role
|
||||
when: client_type == confidential
|
||||
template: external-secrets-keycape-{client_id}-{consuming_service}
|
||||
body: 'auth: kubernetes
|
||||
|
||||
bound_service_account_names: [{consuming_service}-keycape-{client_id}-eso]
|
||||
|
||||
bound_service_account_namespaces: [{consumer_namespace}]
|
||||
|
||||
audience: openbao
|
||||
|
||||
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
|
||||
|
||||
token_no_default_policy: true
|
||||
|
||||
token_ttl: 15m
|
||||
|
||||
token_max_ttl: 15m
|
||||
|
||||
'
|
||||
description: Consumer ESO requests a token for this separate reference identity.
|
||||
Common ESO controller remains trusted and can request tokens; no cryptographic
|
||||
controller isolation is claimed.
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: '{consuming_service}-keycape-client'
|
||||
body: 'id: {consuming_service}-keycape-client
|
||||
|
||||
owner_repo: key-cape
|
||||
|
||||
warden_executes: false
|
||||
|
||||
risk: high
|
||||
|
||||
status: active once verification passes
|
||||
|
||||
workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer''s
|
||||
published declaration or the explicit unknown form, never inferred
|
||||
|
||||
path_template: platform/workloads/keycape/{client_id}
|
||||
|
||||
field: CLIENT_SECRET
|
||||
|
||||
delegation: native, intended_owner key-cape
|
||||
|
||||
rotation: mint-secret then verification; automatable false until a run has been
|
||||
proven
|
||||
|
||||
'
|
||||
description: One entry per case (ops-mason item 5, ops-warden R4'). It carries
|
||||
the consumer's workload reference, which ADR-0009 needs for zone selection.
|
||||
follow_through:
|
||||
- id: ensure-groups
|
||||
owner: net-kingdom
|
||||
entry_point: reviewed adapter net-kingdom/keycape-ensure-groups, dispatched by Warden's
|
||||
case kernel inside the attended session; it calls identity-provisioner
|
||||
ensure_service_groups in the provisioner pod through the session's contained
|
||||
cluster access (groups {access_groups}; members exactly {initial_members} as
|
||||
recorded in the accepted case, nothing else)
|
||||
auth: the attended human session on lane keycape-attended-case-change (actor human,
|
||||
parent-session revoked per attempt); the in-pod call uses the provisioner's own
|
||||
directory binding, and no provisioner or user-engine token is read or sent
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The provisioner's only HTTP credential is user-engine's
|
||||
runtime token, and its NetworkPolicy admits only user-engine pods. A proxy lane
|
||||
would act as user-engine (ops-warden review R8; canon section 3; ops-warden
|
||||
ADR-0002). Running it in the session register-client already needs adds no attended
|
||||
session. It can become unattended once the provisioner has an operator credential
|
||||
of its own and an admitted path. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
|
||||
- id: mint-secret
|
||||
owner: railiance-platform
|
||||
when: client_type == confidential
|
||||
entry_point: 'railiance-platform credential broker: generate a client secret inside
|
||||
custody at bao:platform/workloads/keycape/{client_id}, through reviewed adapter
|
||||
railiance-platform/keycape-mint-secret'
|
||||
auth: a human caller (net-kingdom-admins, aal2) starts it; Warden runs it unattended
|
||||
through the broker lane under the caller's session, with the caller's broker grant
|
||||
for the case's KV path; agents and root are refused, and the value is generated
|
||||
in custody and never returned
|
||||
lane: keycape-client-secret-generate
|
||||
warden_executes: true
|
||||
attended: false
|
||||
value_handling: generated_in_custody
|
||||
- id: deliver-secret
|
||||
owner: railiance-platform
|
||||
when: client_type == confidential
|
||||
entry_point: reviewed adapter railiance-platform/keycape-deliver-secret, dispatched
|
||||
by Warden's case kernel; Platform's deliver capability applies ExternalSecret
|
||||
sso/keycape-{client_id}-client (key client-secret) through the ESO store for
|
||||
role external-secrets-keycape-{client_id}, from bao:platform/workloads/keycape/{client_id}
|
||||
auth: Platform's own delivery path on lane platform-secret-delivery, inside the human
|
||||
aal2 session; ESO reads custody through role external-secrets-keycape-{client_id};
|
||||
Warden holds no Platform credential, and no value leaves custody
|
||||
lane: platform-secret-delivery
|
||||
warden_executes: false
|
||||
attended: false
|
||||
value_handling: none
|
||||
ready_check: the delivery for sso/keycape-{client_id}-client reports Ready=True
|
||||
(status only, no value read)
|
||||
- id: bind-env
|
||||
owner: net-kingdom
|
||||
when: client_type == confidential
|
||||
entry_point: 'reviewed adapter net-kingdom/keycape-bind-env, dispatched by Warden''s
|
||||
case kernel inside the attended session: patch Deployment sso/keycape env
|
||||
KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client, by
|
||||
reference only'
|
||||
auth: the attended human session on lane keycape-attended-case-change, with operator
|
||||
kube credentials for namespace sso (actor human, parent-session revoked per attempt)
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: A change to the live identity provider's Deployment. It
|
||||
runs in the same attended operator session as register-client, before it, because
|
||||
KeyCape resolves a confidential client's secret at start and fails closed if
|
||||
the binding is missing. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
|
||||
- id: register-client
|
||||
owner: net-kingdom
|
||||
entry_point: reviewed adapter net-kingdom/keycape-register-client, dispatched by Warden's
|
||||
case kernel inside the attended session; it runs sso-mfa/k8s/keycape/keycape-client.py
|
||||
apply --client-file <client-from-case> --case-acceptance <record> --check-config
|
||||
<keycape binary> (net-kingdom.keycape-client-registration)
|
||||
auth: the attended human session on lane keycape-attended-case-change, with operator
|
||||
write access to the live KeyCape client registry in namespace sso (actor human,
|
||||
parent-session revoked per attempt)
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: 'The live KeyCape client registry is a Kubernetes Secret,
|
||||
and agents do not read or write Secrets. Until KeyCape loads clients from a
|
||||
non-secret registry with secrets referenced from custody, one attended operator
|
||||
session applies the rendered registration. ops-warden groups all attended steps
|
||||
of a run into that one session. KeyCape loads clients at startup and keeps login,
|
||||
authorization and handoff state in memory, so the restart drops in-flight logins
|
||||
and sessions for every client; runs go in a quiet window (key-cape type review
|
||||
R4). The rendered registry must pass KeyCape''s own startup checks (keycape
|
||||
check-config, the pinned checker artifact sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5
|
||||
built from key-cape 3c71226, which keycape-client.py runs before it patches)
|
||||
before the restart: a refused entry stops the single replica and every client
|
||||
loses sign-in (R1). Warden''s case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.'
|
||||
- id: deliver-consumer-secret
|
||||
owner: '{consumer_owner_repo}'
|
||||
when: client_type == confidential
|
||||
entry_point: Reviewed adapter net-kingdom/keycape-deliver-consumer-secret, dispatched
|
||||
by Warden's case kernel inside the attended consumer-owner session; the owner's
|
||||
apply capability creates dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso
|
||||
(automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer
|
||||
with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client
|
||||
with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}.
|
||||
Admission validates actual installed ESO TokenRequest support and common controller
|
||||
RBAC before any owner-approved apply.
|
||||
auth: the attended consumer-owner session on lane consumer-owner-deployment (actor
|
||||
human, the consumer owner's operator deployment authority, on the founder's go
|
||||
for the change); ESO authenticates to OpenBao role external-secrets-keycape-{client_id}-{consuming_service}
|
||||
with a TokenRequest for the dedicated namespaced serviceAccountRef; controller
|
||||
remains trusted
|
||||
lane: consumer-owner-deployment
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The consumer owner's only deployment authority in the consumer
|
||||
namespace is its operator credential, and production applies need the founder's
|
||||
go per change. No contained agent deployer identity exists, and the owner does
|
||||
not want a standing credential an agent could trigger. It runs in one attended
|
||||
consumer-owner session together with hand-over. Warden's case kernel dispatches
|
||||
the reviewed adapter only inside that session (actor human, parent-session
|
||||
revoked per attempt); the human session is the authority and the kernel is only
|
||||
the dispatcher.
|
||||
ready_check: Namespace SecretStore and ExternalSecret both report Ready=True;
|
||||
dedicated SA has no legacy token refs; status and fixed metadata only, never
|
||||
Secret values.
|
||||
- id: hand-over
|
||||
owner: '{consumer_owner_repo}'
|
||||
entry_point: The consumer owner first makes its own Helm change configuring {consuming_service}
|
||||
with issuer {issuer}, client {client_id} and custody bao:platform/workloads/keycape/{client_id},
|
||||
requiring one of {access_groups} in the groups claim before sign-in completes.
|
||||
Then reviewed adapter net-kingdom/keycape-hand-over, dispatched by Warden's case
|
||||
kernel inside the attended consumer-owner session, verifies the consumer with
|
||||
four value-free probes (member-login, non-member-refused, membership-add-grants,
|
||||
membership-remove-revokes) and records the hand-over receipt.
|
||||
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
|
||||
parent-session revoked per attempt); the consumer configuration itself is the
|
||||
owner's own deployment path
|
||||
lane: apps-consumer-handover
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The verification needs a real member login and a refused
|
||||
non-member login, which only an attended session can perform, and it follows
|
||||
the owner's own Helm change in the same consumer-owner session as
|
||||
deliver-consumer-secret. Warden's case kernel dispatches the reviewed adapter
|
||||
only inside that session (actor human, parent-session revoked per attempt); the
|
||||
human session is the authority and the kernel is only the dispatcher.
|
||||
verification:
|
||||
- id: authorize-accepts-redirects
|
||||
check: GET {issuer}/authorize with client {client_id} and each of {redirect_uris}
|
||||
returns the login page
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: authorize-refuses-other-redirect
|
||||
check: GET {issuer}/authorize with client {client_id} and an unregistered redirect
|
||||
returns an OAuth error
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-denied-to-default
|
||||
when: client_type == confidential
|
||||
check: a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-allowed-to-consumer
|
||||
when: client_type == confidential
|
||||
check: policy workload-kv-read-keycape-{client_id}-{consuming_service} grants
|
||||
read on bao:platform/workloads/keycape/{client_id} (capabilities check only)
|
||||
expect: allow
|
||||
reads_secret_value: false
|
||||
- id: groups-present
|
||||
check: LLDAP groups {access_groups} exist, with exactly the memberships of the
|
||||
accepted case added
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: consumer-enforces-groups
|
||||
check: '{consuming_service}''s OIDC source for {client_id} (owner {consumer_owner_repo})
|
||||
requires one of {access_groups} in the groups claim (configuration read, no
|
||||
secret value)'
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: secret-delivered
|
||||
when: client_type == confidential
|
||||
check: the delivery for sso/keycape-{client_id}-client reports Ready=True (status
|
||||
only)
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: mfa-pinned
|
||||
when: mfa_required == true
|
||||
check: keycape-client.py inspect for {client_id} shows the MFA pin on the registered
|
||||
client (non-secret client view)
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: consumer-secret-delivered
|
||||
when: client_type == confidential
|
||||
check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer
|
||||
and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact
|
||||
dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value
|
||||
read'
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
rollback:
|
||||
- reverses: hand-over
|
||||
action: the adapter removes only its hand-over receipt. Removing the OIDC source
|
||||
for {client_id} from {consuming_service} is the owner's Helm rollback (owner
|
||||
{consumer_owner_repo}), done inside deliver-consumer-secret's owner_remove
|
||||
before any consumer object is removed
|
||||
- reverses: deliver-consumer-secret
|
||||
when: client_type == confidential
|
||||
action: After reverting consumer secret reference, delete only authenticated created
|
||||
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
|
||||
dedicated SA; preserve preexisting-matched resources and never delete a shared
|
||||
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
|
||||
not immediate OpenBao token revocation.
|
||||
- reverses: register-client
|
||||
action: keycape-client.py apply --operation remove for {client_id}
|
||||
- reverses: bind-env
|
||||
when: client_type == confidential
|
||||
action: remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape,
|
||||
after register-client is reversed
|
||||
- reverses: deliver-secret
|
||||
when: client_type == confidential
|
||||
action: delete ExternalSecret sso/keycape-{client_id}-client and its target
|
||||
- reverses: mint-secret
|
||||
when: client_type == confidential
|
||||
action: 'covered by client-secret-path: one metadata delete, after deliver-secret
|
||||
is reversed'
|
||||
- reverses: ensure-groups
|
||||
action: identity-provisioner revoke_service_groups inside the provisioner pod
|
||||
(attended, as ensure-groups) with the case's memberships; delete_if_empty only
|
||||
if no other case uses {access_groups}
|
||||
- reverses: client-secret-path
|
||||
when: client_type == confidential
|
||||
action: delete metadata and all versions at platform/workloads/keycape/{client_id},
|
||||
after the ExternalSecret is gone (this also reverses mint-secret)
|
||||
- reverses: eso-role
|
||||
when: client_type == confidential
|
||||
action: delete Kubernetes-auth role external-secrets-keycape-{client_id}
|
||||
- reverses: keycape-reader-policy
|
||||
when: client_type == confidential
|
||||
action: delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role
|
||||
- reverses: consumer-eso-role
|
||||
when: client_type == confidential
|
||||
action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service},
|
||||
before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or
|
||||
prove expiration before declaring deactivation.
|
||||
- reverses: consumer-reader-policy
|
||||
when: client_type == confidential
|
||||
action: delete policy workload-kv-read-keycape-{client_id}-{consuming_service}
|
||||
- reverses: catalog-entry
|
||||
action: remove catalog entry {consuming_service}-keycape-client
|
||||
acceptance:
|
||||
accepted_by:
|
||||
- owner
|
||||
- founder
|
||||
risk_class: medium
|
||||
# Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are
|
||||
# generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3.
|
||||
execution_descriptors:
|
||||
bind-env: sha256:d5fcb20cf15bf7454890d2529180c582218e3df12584a99162e4b664b899caa0
|
||||
deliver-consumer-secret: sha256:36281df8139fb1ec64b226d94f80eb592de5addab7063ff54c691c641e275fc7
|
||||
deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85
|
||||
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
|
||||
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
|
||||
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
|
||||
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
apiVersion: netkingdom.io/security-case-type/v0.1
|
||||
kind: SecurityCaseType
|
||||
spec:
|
||||
id: oidc-browser-relying-party
|
||||
version: '1.3'
|
||||
status: proposed
|
||||
title: Browser OIDC relying party for an internal service
|
||||
purpose: 'Let an internal web service sign its human users in through NetKingdom
|
||||
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups the
|
||||
service maps to its own roles, and, for confidential clients, a client secret
|
||||
minted straight into OpenBao custody. Replaces the per-client rollout scripts
|
||||
in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).'
|
||||
parameters:
|
||||
- name: client_id
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
|
||||
sensitivity: operational
|
||||
description: OAuth2 client_id, unique in KeyCape.
|
||||
- name: display_name
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[^\n\r]{1,80}$
|
||||
sensitivity: public
|
||||
description: Name shown on the sign-in page.
|
||||
- name: redirect_uris
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^https://[^*?#\s,]+(,https://[^*?#\s,]+)*$
|
||||
sensitivity: security_sensitive
|
||||
description: 'Comma-separated exact https redirect URIs. No wildcards, no plain
|
||||
http, no query and no fragment: KeyCape refuses ''*'' and ''?'' at startup,
|
||||
and OAuth 2.0 forbids fragments (key-cape type review R1).'
|
||||
- name: allowed_scopes
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- openid,profile,email,groups
|
||||
- openid,email,groups
|
||||
- openid,profile,groups
|
||||
- openid,groups
|
||||
sensitivity: security_sensitive
|
||||
description: Scopes the client may request, openid first. Always includes groups,
|
||||
because access_groups is required and KeyCape's UserInfo returns groups only
|
||||
for that scope (key-cape type review R2).
|
||||
- name: client_type
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- confidential
|
||||
- public
|
||||
sensitivity: security_sensitive
|
||||
description: confidential for server-side services that can hold a secret; public
|
||||
for SPAs and native apps (PKCE only).
|
||||
- name: mfa_required
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
enum:
|
||||
- 'true'
|
||||
- 'false'
|
||||
sensitivity: security_sensitive
|
||||
description: '"true" pins MFA on this client; required when the service grants
|
||||
administrative or owner rights. "false" is not "no MFA": the client then carries
|
||||
no mfaRequired field and KeyCape applies the per-user privacyIDEA policy. A
|
||||
renderer must never emit an explicit mfaRequired false, which would skip that
|
||||
policy (key-cape type review R3).'
|
||||
- name: access_groups
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
|
||||
sensitivity: operational
|
||||
description: 'Comma-separated LLDAP groups that the service maps to its roles.
|
||||
Created if absent. KeyCape has no per-client group gate: it issues tokens to
|
||||
any authenticated user and only carries the groups claim, so the consuming service
|
||||
enforces the gate itself (for Forgejo, its required-claim setting; key-cape
|
||||
type review R2).'
|
||||
- name: initial_members
|
||||
type: string
|
||||
required: false
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*(;[a-z][a-z0-9-]*=[a-z][a-z0-9._-]*(\+[a-z][a-z0-9._-]*)*)*$
|
||||
sensitivity: operational
|
||||
description: Initial memberships as group=user+user;group=user. Usernames are
|
||||
directory usernames, never secrets.
|
||||
- name: consuming_service
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]{0,62}$
|
||||
sensitivity: operational
|
||||
description: The service that signs users in with this client.
|
||||
- name: consumer_owner_repo
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9][a-z0-9-]*$
|
||||
sensitivity: operational
|
||||
description: The repository that owns the consuming service's configuration.
|
||||
- name: consumer_namespace
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$
|
||||
sensitivity: security_sensitive
|
||||
description: Consumer namespace for namespaced ESO TokenRequest reader; actual
|
||||
workload owner must confirm; never infer from service name.
|
||||
- name: issuer
|
||||
type: string
|
||||
case_settable: false
|
||||
default: https://kc.coulomb.social
|
||||
sensitivity: security_sensitive
|
||||
description: The NetKingdom issuer; fixed by the type.
|
||||
composition:
|
||||
authority: netkingdom
|
||||
initial_trust:
|
||||
- runtime_secret_trust
|
||||
- runtime_identity_trust
|
||||
capabilities:
|
||||
- c1.lightweight-sso
|
||||
providers:
|
||||
c1.lightweight-sso: net-kingdom.keycape-client-registration
|
||||
parameter_bindings:
|
||||
net-kingdom.keycape-client-registration.client_id: client_id
|
||||
net-kingdom.keycape-client-registration.display_name: display_name
|
||||
net-kingdom.keycape-client-registration.redirect_uris: redirect_uris
|
||||
net-kingdom.keycape-client-registration.allowed_scopes: allowed_scopes
|
||||
net-kingdom.keycape-client-registration.client_type: client_type
|
||||
net-kingdom.keycape-client-registration.mfa_required: mfa_required
|
||||
materialization:
|
||||
- id: client-secret-path
|
||||
kind: kv_path
|
||||
when: client_type == confidential
|
||||
template: platform/workloads/keycape/{client_id}
|
||||
body: field CLIENT_SECRET; minted by the credential broker, never written by hand
|
||||
- id: keycape-reader-policy
|
||||
kind: openbao_policy
|
||||
when: client_type == confidential
|
||||
template: workload-kv-read-keycape-{client_id}-eso
|
||||
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
|
||||
\ = [\"read\"]\n}}\n"
|
||||
description: KeyCape reads its client secret only through ESO. No metadata, list,
|
||||
parent or sibling access.
|
||||
- id: consumer-reader-policy
|
||||
kind: openbao_policy
|
||||
when: client_type == confidential
|
||||
template: workload-kv-read-keycape-{client_id}-{consuming_service}
|
||||
body: "path \"platform/data/workloads/keycape/{client_id}\" {{\n capabilities\
|
||||
\ = [\"read\"]\n}}\n"
|
||||
description: The consuming service reads the same secret. No metadata, list, parent
|
||||
or sibling access.
|
||||
- id: eso-role
|
||||
kind: openbao_role
|
||||
when: client_type == confidential
|
||||
template: external-secrets-keycape-{client_id}
|
||||
body: 'auth: kubernetes
|
||||
|
||||
bound_service_account_names: [external-secrets]
|
||||
|
||||
bound_service_account_namespaces: [external-secrets]
|
||||
|
||||
token_policies: [workload-kv-read-keycape-{client_id}-eso]
|
||||
|
||||
token_no_default_policy: true
|
||||
|
||||
token_ttl: 15m
|
||||
|
||||
token_max_ttl: 15m
|
||||
|
||||
'
|
||||
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service
|
||||
account only.
|
||||
- id: consumer-eso-role
|
||||
kind: openbao_role
|
||||
when: client_type == confidential
|
||||
template: external-secrets-keycape-{client_id}-{consuming_service}
|
||||
body: 'auth: kubernetes
|
||||
|
||||
bound_service_account_names: [{consuming_service}-keycape-{client_id}-eso]
|
||||
|
||||
bound_service_account_namespaces: [{consumer_namespace}]
|
||||
|
||||
audience: openbao
|
||||
|
||||
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
|
||||
|
||||
token_no_default_policy: true
|
||||
|
||||
token_ttl: 15m
|
||||
|
||||
token_max_ttl: 15m
|
||||
|
||||
'
|
||||
description: Consumer ESO requests a token for this separate reference identity.
|
||||
Common ESO controller remains trusted and can request tokens; no cryptographic
|
||||
controller isolation is claimed.
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: '{consuming_service}-keycape-client'
|
||||
body: 'id: {consuming_service}-keycape-client
|
||||
|
||||
owner_repo: key-cape
|
||||
|
||||
warden_executes: false
|
||||
|
||||
risk: high
|
||||
|
||||
status: active once verification passes
|
||||
|
||||
workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer''s
|
||||
published declaration or the explicit unknown form, never inferred
|
||||
|
||||
path_template: platform/workloads/keycape/{client_id}
|
||||
|
||||
field: CLIENT_SECRET
|
||||
|
||||
delegation: native, intended_owner key-cape
|
||||
|
||||
rotation: mint-secret then verification; automatable false until a run has been
|
||||
proven
|
||||
|
||||
'
|
||||
description: One entry per case (ops-mason item 5, ops-warden R4'). It carries
|
||||
the consumer's workload reference, which ADR-0009 needs for zone selection.
|
||||
follow_through:
|
||||
- id: ensure-groups
|
||||
owner: net-kingdom
|
||||
entry_point: reviewed adapter net-kingdom/keycape-ensure-groups, dispatched by Warden's
|
||||
case kernel inside the attended session; it calls identity-provisioner
|
||||
ensure_service_groups in the provisioner pod through the session's contained
|
||||
cluster access (groups {access_groups}; members exactly {initial_members} as
|
||||
recorded in the accepted case, nothing else)
|
||||
auth: the attended human session on lane keycape-attended-case-change (actor human,
|
||||
parent-session revoked per attempt); the in-pod call uses the provisioner's own
|
||||
directory binding, and no provisioner or user-engine token is read or sent
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The provisioner's only HTTP credential is user-engine's
|
||||
runtime token, and its NetworkPolicy admits only user-engine pods. A proxy lane
|
||||
would act as user-engine (ops-warden review R8; canon section 3; ops-warden
|
||||
ADR-0002). Running it in the session register-client already needs adds no attended
|
||||
session. It can become unattended once the provisioner has an operator credential
|
||||
of its own and an admitted path. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
|
||||
- id: mint-secret
|
||||
owner: railiance-platform
|
||||
when: client_type == confidential
|
||||
entry_point: 'railiance-platform credential broker: generate a client secret inside
|
||||
custody at bao:platform/workloads/keycape/{client_id}, through reviewed adapter
|
||||
railiance-platform/keycape-mint-secret'
|
||||
auth: a human caller (net-kingdom-admins, aal2) starts it; Warden runs it unattended
|
||||
through the broker lane under the caller's session, with the caller's broker grant
|
||||
for the case's KV path; agents and root are refused, and the value is generated
|
||||
in custody and never returned
|
||||
lane: keycape-client-secret-generate
|
||||
warden_executes: true
|
||||
attended: false
|
||||
value_handling: generated_in_custody
|
||||
- id: deliver-secret
|
||||
owner: railiance-platform
|
||||
when: client_type == confidential
|
||||
entry_point: reviewed adapter railiance-platform/keycape-deliver-secret, dispatched
|
||||
by Warden's case kernel; Platform's deliver capability applies ExternalSecret
|
||||
sso/keycape-{client_id}-client (key client-secret) through the ESO store for
|
||||
role external-secrets-keycape-{client_id}, from bao:platform/workloads/keycape/{client_id}
|
||||
auth: Platform's own delivery path on lane platform-secret-delivery, inside the human
|
||||
aal2 session; ESO reads custody through role external-secrets-keycape-{client_id};
|
||||
Warden holds no Platform credential, and no value leaves custody
|
||||
lane: platform-secret-delivery
|
||||
warden_executes: false
|
||||
attended: false
|
||||
value_handling: none
|
||||
ready_check: the delivery for sso/keycape-{client_id}-client reports Ready=True
|
||||
(status only, no value read)
|
||||
- id: bind-env
|
||||
owner: net-kingdom
|
||||
when: client_type == confidential
|
||||
entry_point: 'reviewed adapter net-kingdom/keycape-bind-env, dispatched by Warden''s
|
||||
case kernel inside the attended session: patch Deployment sso/keycape env
|
||||
KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client, by
|
||||
reference only'
|
||||
auth: the attended human session on lane keycape-attended-case-change, with operator
|
||||
kube credentials for namespace sso (actor human, parent-session revoked per attempt)
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: A change to the live identity provider's Deployment. It
|
||||
runs in the same attended operator session as register-client, before it, because
|
||||
KeyCape resolves a confidential client's secret at start and fails closed if
|
||||
the binding is missing. Warden's case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.
|
||||
- id: register-client
|
||||
owner: net-kingdom
|
||||
entry_point: reviewed adapter net-kingdom/keycape-register-client, dispatched by Warden's
|
||||
case kernel inside the attended session; it runs sso-mfa/k8s/keycape/keycape-client.py
|
||||
apply --client-file <client-from-case> --case-acceptance <record> --check-config
|
||||
<keycape binary> (net-kingdom.keycape-client-registration)
|
||||
auth: the attended human session on lane keycape-attended-case-change, with operator
|
||||
write access to the live KeyCape client registry in namespace sso (actor human,
|
||||
parent-session revoked per attempt)
|
||||
lane: keycape-attended-case-change
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: 'The live KeyCape client registry is a Kubernetes Secret,
|
||||
and agents do not read or write Secrets. Until KeyCape loads clients from a
|
||||
non-secret registry with secrets referenced from custody, one attended operator
|
||||
session applies the rendered registration. ops-warden groups all attended steps
|
||||
of a run into that one session. KeyCape loads clients at startup and keeps login,
|
||||
authorization and handoff state in memory, so the restart drops in-flight logins
|
||||
and sessions for every client; runs go in a quiet window (key-cape type review
|
||||
R4). The rendered registry must pass KeyCape''s own startup checks (keycape
|
||||
check-config, the pinned checker artifact sha256:be37738fe1c08259fb4f9ca0daf0f9301bfffc237e5bb38435525dfbf11ccff5
|
||||
built from key-cape 3c71226, which keycape-client.py runs before it patches)
|
||||
before the restart: a refused entry stops the single replica and every client
|
||||
loses sign-in (R1). Warden''s case kernel dispatches the reviewed adapter only inside the one attended human session (descriptor actor human, lane keycape-attended-case-change, parent-session revoked per attempt); the human session is the authority and the kernel is only the dispatcher.'
|
||||
- id: deliver-consumer-secret
|
||||
owner: '{consumer_owner_repo}'
|
||||
when: client_type == confidential
|
||||
entry_point: Reviewed adapter net-kingdom/keycape-deliver-consumer-secret, dispatched
|
||||
by Warden's case kernel inside the attended consumer-owner session; the owner's
|
||||
apply capability creates dedicated ServiceAccount {consumer_namespace}/{consuming_service}-keycape-{client_id}-eso
|
||||
(automount=false, no legacy tokens), namespace SecretStore openbao-keycape-{client_id}-consumer
|
||||
with serviceAccountRef and audiences [openbao], and ExternalSecret {consuming_service}-netkingdom-oidc-client
|
||||
with Owner target, Retain remote deletion and only CLIENT_SECRET from workloads/keycape/{client_id}.
|
||||
Admission validates actual installed ESO TokenRequest support and common controller
|
||||
RBAC before any owner-approved apply.
|
||||
auth: the attended consumer-owner session on lane consumer-owner-deployment (actor
|
||||
human, the consumer owner's operator deployment authority, on the founder's go
|
||||
for the change); ESO authenticates to OpenBao role external-secrets-keycape-{client_id}-{consuming_service}
|
||||
with a TokenRequest for the dedicated namespaced serviceAccountRef; controller
|
||||
remains trusted
|
||||
lane: consumer-owner-deployment
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The consumer owner's only deployment authority in the consumer
|
||||
namespace is its operator credential, and production applies need the founder's
|
||||
go per change. No contained agent deployer identity exists, and the owner does
|
||||
not want a standing credential an agent could trigger. It runs in one attended
|
||||
consumer-owner session together with hand-over. Warden's case kernel dispatches
|
||||
the reviewed adapter only inside that session (actor human, parent-session
|
||||
revoked per attempt); the human session is the authority and the kernel is only
|
||||
the dispatcher.
|
||||
ready_check: Namespace SecretStore and ExternalSecret both report Ready=True;
|
||||
dedicated SA has no legacy token refs; status and fixed metadata only, never
|
||||
Secret values.
|
||||
- id: hand-over
|
||||
owner: '{consumer_owner_repo}'
|
||||
entry_point: The consumer owner's reviewed deployment entry wraps its own Helm
|
||||
configuration and the admitted hand-over in one owner configuration transaction,
|
||||
for public clients as well as confidential clients even when delivery is NO_CHANGE.
|
||||
Before changing {consuming_service}, durably bind exact case, acceptance, human
|
||||
principal, owner configuration source, cluster/release and before revision;
|
||||
authenticate the case-created post revision under the owner's release-writer
|
||||
exclusion before verification. Configure issuer {issuer}, client {client_id}
|
||||
and, only for confidential clients, custody bao:platform/workloads/keycape/{client_id},
|
||||
requiring one of {access_groups} before sign-in completes. Preserve identical
|
||||
preexisting configuration as NO_CHANGE and refuse conflicts. Then the reviewed
|
||||
adapter net-kingdom/keycape-hand-over, dispatched by Warden's case kernel inside
|
||||
the attended consumer-owner session, verifies the four value-free probes
|
||||
(member-login, non-member-refused, membership-add-grants, membership-remove-revokes)
|
||||
and records the receipt. The owner wrapper's separate configuration inverse is
|
||||
reachable on failure before a receipt exists and on later owner case unwind;
|
||||
it does not depend on delivery-created objects or receipt creation. Missing or
|
||||
ambiguous configuration provenance requires owner reconciliation, never replay.
|
||||
auth: the attended consumer-owner session on lane apps-consumer-handover (actor human,
|
||||
parent-session revoked per attempt); the consumer configuration itself is the
|
||||
owner's own deployment path. Every configuration inverse is a distinct fresh
|
||||
authorized attended owner operation, bound to operation, exact principal, case,
|
||||
source, release and transaction, with its own per-attempt revocation on every
|
||||
exit. Never reuse the already revoked hand-over parent session. Missing current
|
||||
inverse authority retains pending owner reconciliation evidence
|
||||
lane: apps-consumer-handover
|
||||
warden_executes: false
|
||||
attended: true
|
||||
value_handling: none
|
||||
attended_justification: The verification needs a real member login and a refused
|
||||
non-member login, which only an attended session can perform, and it follows
|
||||
the owner's own Helm change in the same consumer-owner session as
|
||||
deliver-consumer-secret. Warden's case kernel dispatches the reviewed adapter
|
||||
only inside that session (actor human, parent-session revoked per attempt); the
|
||||
human session is the authority and the kernel is only the dispatcher.
|
||||
verification:
|
||||
- id: authorize-accepts-redirects
|
||||
check: GET {issuer}/authorize with client {client_id} and each of {redirect_uris}
|
||||
returns the login page
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: authorize-refuses-other-redirect
|
||||
check: GET {issuer}/authorize with client {client_id} and an unregistered redirect
|
||||
returns an OAuth error
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-denied-to-default
|
||||
when: client_type == confidential
|
||||
check: a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-allowed-to-consumer
|
||||
when: client_type == confidential
|
||||
check: policy workload-kv-read-keycape-{client_id}-{consuming_service} grants
|
||||
read on bao:platform/workloads/keycape/{client_id} (capabilities check only)
|
||||
expect: allow
|
||||
reads_secret_value: false
|
||||
- id: groups-present
|
||||
check: LLDAP groups {access_groups} exist, with exactly the memberships of the
|
||||
accepted case added
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: consumer-enforces-groups
|
||||
check: '{consuming_service}''s OIDC source for {client_id} (owner {consumer_owner_repo})
|
||||
requires one of {access_groups} in the groups claim (configuration read, no
|
||||
secret value)'
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: secret-delivered
|
||||
when: client_type == confidential
|
||||
check: the delivery for sso/keycape-{client_id}-client reports Ready=True (status
|
||||
only)
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: mfa-pinned
|
||||
when: mfa_required == true
|
||||
check: keycape-client.py inspect for {client_id} shows the MFA pin on the registered
|
||||
client (non-secret client view)
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: consumer-secret-delivered
|
||||
when: client_type == confidential
|
||||
check: '{consumer_namespace} SecretStore openbao-keycape-{client_id}-consumer
|
||||
and ExternalSecret {consuming_service}-netkingdom-oidc-client Ready=True, exact
|
||||
dedicated SA/audience role binding, fixed CLIENT_SECRET mapping; no Secret value
|
||||
read'
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
rollback:
|
||||
- reverses: hand-over
|
||||
action: the adapter removes only its authenticated case-created hand-over receipt.
|
||||
Independently, the consumer owner's configuration transaction inverse restores
|
||||
only this case's configuration change through the noted before Helm revision,
|
||||
after authenticating exact case/principal/source/release binding and checking
|
||||
the unchanged post revision under exclusion of all release writers. It runs
|
||||
on owner unwind for public and confidential cases, including skipped or NO_CHANGE
|
||||
delivery and failures before receipt creation. Preserve preexisting configuration;
|
||||
drift or ambiguous provenance requires owner reconciliation. This owner inverse
|
||||
is outside the receipt adapter and precedes any delivery-object removal;
|
||||
receipt removal alone never proves configuration rollback.
|
||||
- reverses: deliver-consumer-secret
|
||||
when: client_type == confidential
|
||||
action: First verify the owner configuration transaction inverse and absence of
|
||||
remaining consumer secret references; NO_CHANGE configuration alone is not proof of
|
||||
deactivation. Do not repeat an unguarded Helm rollback inside owner_remove.
|
||||
Then delete only authenticated created
|
||||
ExternalSecret/owned target by UID and ownerReference, then SecretStore and
|
||||
dedicated SA; preserve preexisting-matched resources and never delete a shared
|
||||
target. Retain audit and ownership receipts; removing SecretStore/SA alone is
|
||||
not immediate OpenBao token revocation.
|
||||
- reverses: register-client
|
||||
action: keycape-client.py apply --operation remove for {client_id}
|
||||
- reverses: bind-env
|
||||
when: client_type == confidential
|
||||
action: remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape,
|
||||
after register-client is reversed
|
||||
- reverses: deliver-secret
|
||||
when: client_type == confidential
|
||||
action: delete ExternalSecret sso/keycape-{client_id}-client and its target
|
||||
- reverses: mint-secret
|
||||
when: client_type == confidential
|
||||
action: 'covered by client-secret-path: one metadata delete, after deliver-secret
|
||||
is reversed'
|
||||
- reverses: ensure-groups
|
||||
action: identity-provisioner revoke_service_groups inside the provisioner pod
|
||||
(attended, as ensure-groups) with the case's memberships; delete_if_empty only
|
||||
if no other case uses {access_groups}
|
||||
- reverses: client-secret-path
|
||||
when: client_type == confidential
|
||||
action: delete metadata and all versions at platform/workloads/keycape/{client_id},
|
||||
after the ExternalSecret is gone (this also reverses mint-secret)
|
||||
- reverses: eso-role
|
||||
when: client_type == confidential
|
||||
action: delete Kubernetes-auth role external-secrets-keycape-{client_id}
|
||||
- reverses: keycape-reader-policy
|
||||
when: client_type == confidential
|
||||
action: delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role
|
||||
- reverses: consumer-eso-role
|
||||
when: client_type == confidential
|
||||
action: Delete only this case-created role external-secrets-keycape-{client_id}-{consuming_service},
|
||||
before deleting consumer-reader-policy; revoke case-issued OpenBao tokens or
|
||||
prove expiration before declaring deactivation.
|
||||
- reverses: consumer-reader-policy
|
||||
when: client_type == confidential
|
||||
action: delete policy workload-kv-read-keycape-{client_id}-{consuming_service}
|
||||
- reverses: catalog-entry
|
||||
action: remove catalog entry {consuming_service}-keycape-client
|
||||
acceptance:
|
||||
accepted_by:
|
||||
- owner
|
||||
- founder
|
||||
risk_class: medium
|
||||
# Reviewed machine descriptors (NK-WP-0048-T16 successor candidate). Digests are
|
||||
# generated by sso-mfa/k8s/keycape/compose-1.2-descriptors.py --version 1.3.
|
||||
execution_descriptors:
|
||||
bind-env: sha256:d5fcb20cf15bf7454890d2529180c582218e3df12584a99162e4b664b899caa0
|
||||
deliver-consumer-secret: sha256:36281df8139fb1ec64b226d94f80eb592de5addab7063ff54c691c641e275fc7
|
||||
deliver-secret: sha256:75aaa7346aebdea2a0eb0ba993995515b5248ffefc34caf8e713525e9b58da85
|
||||
ensure-groups: sha256:ebc9d5b9167d18e6015f4bbeb9699e8d396d64fbfaf195142781f84f4f47a70a
|
||||
hand-over: sha256:1bce07a3229b7d0c7c730f35ce5b40fe471009a82de8dd53788ed970a9be1224
|
||||
mint-secret: sha256:c92957227235db04953809d92b260d32c8b947472a8e8fd17a258ade3ae8da70
|
||||
register-client: sha256:4ecc769f75433b66c7161f13c0fe7e5599633cf466d46cb6d8031f0456f426e7
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
# Independent Apps-scoped F3 source review
|
||||
|
||||
Verdict: **needs-change (RAPPS-F3-SESSION-1)**. The external owner configuration transaction is a coherent source design for public clients and confidential delivery NO_CHANGE. The proposed executable inverse omits a distinct fresh attended inverse-operation authorization/session boundary. This review is coordinator-delegated source review, not an actual railiance-apps owner acceptance, native grant or published implementation.
|
||||
|
||||
Reviewed Apps `944b30ad63ab054cc24617e374e177d34cd572da` and NetKingdom `3236d5000e52e0643f33906a26a7825aa0b67465`. `review.json` records exact candidate/source SHA-256 values and independently checked all 17 retained input pins against their current bytes. Sources include Apps AGENTS/INTENT, owner-design, actual T16/1.3 review records, Makefile and RAPPS-WP-0016. Original candidate wrapper SHA-256: `62752f518b78fa7713bdb4d47e68decbf3aa89ecbc7950549ffdc3d08e01c554`; candidate type SHA-256: `739c927ab494f4e0f6618e6e55394dd8dc4b720db428cd4973f7a81524e1ecd9`.
|
||||
|
||||
## Finding and supplied correction
|
||||
|
||||
The real frozen hand-over adapter's `execute` invokes `revoke_session(inv)` in `finally` on success and failure. Candidate `run` calls `inverse` only after hand-over exits. Its `inverse` then takes only the historical binding and transaction plus `owner.exclusive`; it never asks a backend to authenticate current inverse authority. In fixtures the original session is already revoked and Helm rollback nevertheless succeeds. A durable authenticated transaction proves provenance, not current permission. Retaining the same human principal is compatible with a fresh operation session; the revoked original session cannot authorize that new operation.
|
||||
|
||||
Three independent regressions fail against the original candidate and pass against the separate scratch correction: deny inverse after successful hand-over revocation; deny automatic failure unwind after revocation while retaining confirmed pending work; require fresh scoped session entry and revocation around a permitted inverse. The original 13 tests independently pass unchanged. Runs use explicit clean environments, frozen local source imports, no network and synthetic callbacks. Failed and corrected outputs are retained as `original-authority-tests.txt` and `patched-authority-tests.txt`.
|
||||
|
||||
`fresh-inverse-session.candidate.patch` adds a mandatory `owner.inverse_session(binding, transaction)` context before the release lock. This is a proposed backend interface, not an implemented credential boundary. T05 must supply authenticated operation-specific evidence, exact scope and current disposition, durable attempt/outcome records and final revocation on every exit. A missing fresh authorized attended session must leave the inverse pending with original error context; it cannot be replaced with a no-op context or reuse of the hand-over session. Existing historical authorization can cover that inverse scope where explicitly established; a new user permission ceremony is not implied. The new context models the required runtime authority, not a demand for another workflow.
|
||||
|
||||
Candidate type auth and owner-contract text must explicitly say: “Every configuration inverse runs as a distinct fresh authorized attended owner operation, with its own exact transaction/case/source/release binding and per-attempt revocation. The already revoked hand-over parent session is never reused. Missing current authority retains pending inverse/reconciliation evidence.” The separate patch intentionally does not edit canonical type/owner files or invent owner assent.
|
||||
|
||||
## Placement, exclusion and recovery
|
||||
|
||||
The proposed wrapper/inverse placement solves the reachability defect at source-design level: it covers public clients with delivery skipped and confidential delivery NO_CHANGE, and verification/receipt failures before receipt creation. Keeping receipt rollback receipt-only and delivery cleanup created-only preserves the existing authority boundaries. Preexisting configuration remains NO_CHANGE; reference deactivation is separately required before created delivery cleanup. Actual 1.3 C3 placed the inverse in owner_remove; this proposal needs explicit owner adoption of its revised placement and cannot inherit C3 assent.
|
||||
|
||||
The current Forgejo Make target performs a regular Helm upgrade, has prerequisite production applies, and mixes values inputs including a secret-producing helper. It supplies no durable transaction, authenticated configuration projection, operation provenance or all-writer exclusion. T05 must publish a narrowly reviewed source/configuration projection preserving local login and unrelated changes; it cannot classify an entire ordinary release upgrade as this case's exclusive configuration mutation. No values, rendered manifests or helper outputs were executed/read during this review.
|
||||
|
||||
The candidate correctly requires all release writers excluded across read-current and rollback. Helm has no CAS here. A local lock that only this wrapper honors is insufficient: manual Helm, automation and any reconciliation writer must share an enforced release boundary or an attended owner-controlled release freeze covering the critical section. Source review accepts that requirement; no real boundary is implemented or verified by this packet. A raw revision check followed by rollback remains rejected.
|
||||
|
||||
The pending-intent/confirmed/restored model safely refuses unproven mutation ownership and drift. Loss after configuration but before confirmation, or rollback but before durable inverse confirmation, still requires owner reconciliation against authenticated history and operation/source binding. T05 must retain inverse-attempt intent/outcome, classify ambiguous outcomes and read back the approved before configuration; it must not replay a rollback, infer NO_CHANGE or claim whole-case success while publication/receipt uncertainty remains. Recovery is plausible within the existing T05 implementation, but its durable backend is absent.
|
||||
|
||||
## Concrete existing T05 work remaining
|
||||
|
||||
Implement and publish the wrapper entry plus independently reachable inverse, the mandatory fresh inverse-operation session lifecycle, authenticated durable journal/recovery, exact accepted source/configuration binding and all-writer release exclusion. Reconcile the revised C3 type prose with actual Apps owner acceptance. Complete real owner_apply/owner_remove with authenticated created UIDs and reference-deactivation proof, all four observed Forgejo probe records, and the committed evidence store/revert behavior. Preserve T01 break-glass, T02/T03 login/group verification and the existing attended production authority. Exact source publication, kernel/catalog acceptance and separately authorized native evidence remain unfinished; no eighth step, standing credential, new grant or additional workplan is proposed.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
--- a/owner_configuration_contract.py
|
||||
+++ b/owner_configuration_contract.py
|
||||
@@ -11,7 +11,12 @@
|
||||
|
||||
def inverse(binding, transaction, owner):
|
||||
"""Owner unwind entry, reachable without any delivery/hand-over creation."""
|
||||
- with owner.exclusive(binding):
|
||||
+ # This is a distinct operation: hand-over has already revoked its session.
|
||||
+ # Backend must authenticate a fresh attended inverse attempt, exact operation,
|
||||
+ # principal/owner/case/source/release/transaction scope and current disposition;
|
||||
+ # durably retain authority + outcome and revoke this attempt on every exit.
|
||||
+ # Durable configuration provenance alone is never execution authority.
|
||||
+ with owner.inverse_session(binding, transaction), owner.exclusive(binding):
|
||||
record = owner.authenticated_record(transaction)
|
||||
if record['binding'] != binding:
|
||||
raise Refused('configuration transaction binding differs')
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse) ... FAIL
|
||||
test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session) ... FAIL
|
||||
test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore) ... FAIL
|
||||
|
||||
======================================================================
|
||||
FAIL: test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 21, in test_revoked_handover_session_cannot_authorize_later_inverse
|
||||
with self.assertRaisesRegex(Refused, 'fresh attended'):
|
||||
AssertionError: Refused not raised
|
||||
|
||||
======================================================================
|
||||
FAIL: test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session)
|
||||
----------------------------------------------------------------------
|
||||
warden.case_receipt_state.Refused: consumer verification contract differs
|
||||
|
||||
During handling of the above exception, another exception occurred:
|
||||
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 34, in test_failure_unwind_must_not_reuse_revoked_handover_session
|
||||
with self.assertRaisesRegex(Refused, 'fresh attended'):
|
||||
AssertionError: "fresh attended" does not match "consumer verification contract differs"
|
||||
|
||||
======================================================================
|
||||
FAIL: test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore)
|
||||
----------------------------------------------------------------------
|
||||
Traceback (most recent call last):
|
||||
File "/tmp/nk48-apps-f3-review/test_inverse_authority.py", line 54, in test_fresh_scoped_inverse_session_closed_after_restore
|
||||
self.assertEqual(events, ['fresh-scoped-authority', 'inverse-session-revoked'])
|
||||
AssertionError: Lists differ: [] != ['fresh-scoped-authority', 'inverse-session-revoked']
|
||||
|
||||
Second list contains 2 additional elements.
|
||||
First extra element 0:
|
||||
'fresh-scoped-authority'
|
||||
|
||||
- []
|
||||
+ ['fresh-scoped-authority', 'inverse-session-revoked']
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 3 tests in 0.013s
|
||||
|
||||
FAILED (failures=3)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
test_revoked_handover_session_cannot_authorize_later_inverse (__main__.InverseAuthorityTests.test_revoked_handover_session_cannot_authorize_later_inverse) ... ok
|
||||
test_failure_unwind_must_not_reuse_revoked_handover_session (__main__.InverseAuthorityTests.test_failure_unwind_must_not_reuse_revoked_handover_session) ... ok
|
||||
test_fresh_scoped_inverse_session_closed_after_restore (__main__.InverseAuthorityTests.test_fresh_scoped_inverse_session_closed_after_restore) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 3 tests in 0.015s
|
||||
|
||||
OK
|
||||
Loading…
Add table
Add a link
Reference in a new issue