Skip to content

feat(evault): enforce company signing authority on writes with single-use grants and revoke cascade - #1193

Closed
coodos wants to merge 1 commit into
mainfrom
feat/delegation-evault-guard
Closed

coodos wants to merge 1 commit into
mainfrom
feat/delegation-evault-guard

Conversation

@coodos

@coodos coodos commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Description of change

PR 3 of 5 for company signing delegation: evault-core enforces who may write company authority records, so a delegation found in a company's eVault can be trusted.

Governed records are those in the company's own vault: a Company with directors, and Role / Delegation / Shareholding whose companyEName is the vault. Copies elsewhere carry no authority and pass untouched.

  • Signed writes: every governed write must carry a w3ds-grant/v1 authorization verified against the signer's Registry-bound keys (fails closed without a Registry).
  • Who may write:
    • Company board: the creator sets the first board (must be a director); afterwards only an existing director. One board per vault.
    • Role, Shareholding: any director.
    • Delegation from a role: a director; scopes ⊆ role, window within role, mayRedelegate only if the role allows.
    • Re-delegation: only the parent's delegate, parent must be usable (evaluateDelegation) and allow re-delegation; narrowing only.
    • Revoke a delegation: a director or whoever granted it. Revocation is final.
  • Single-use grants (from feat(delegation): scopes, signed payload formats and delegation chain evaluation #1192 review): an accepted authorization is claimed per vault (DelegationGrant, unique constraint). Copying it onto another record, or writing back an older signed state, is REPLAYED_GRANT. Identical re-sends pass. A failed write frees the claim.
  • Cascade: revoking or narrowing a Role/Delegation revokes every live delegation handed on from it that it no longer covers (revocationReason: cascade), recursively.
  • No deletes, rollbacks or single-field edits on governed records (IMMUTABLE); revoke instead.
  • Wired into every write resolver via one guardedWrite helper (create, update ×2, bulk create, legacy store) plus assertNotGoverned on remove/delete/rollback/updateEnvelopeValue. Guard error codes surface in the mutation errors.

DelegatedSignature (the audit record) is not guarded here; platforms write it after verifying (PR 4).

Issue Number

Type of change

  • New (a change which implements a new feature)

How the change has been tested

delegation-write-guard.spec.ts (16, Neo4j testcontainer, fake verifier): first board by creator; unsigned / tampered / outsider boards refused; one board per vault and only directors change it; NO_COMPANY; non-director role; core scopes; wider-than-role delegation; re-delegation by delegate vs outsider; copied grant and older-state replay refused; identical re-send; aborted write frees grant; revoke by grantor/director vs others; revocation final; role revoke cascades through a re-delegation; narrowing cascades only what it no longer covers; delete refused; copies for other companies ignored. Plus a GraphQL test that an unsigned Role returns UNSIGNED. Existing suites (db, acl, manifest, protocol: 200 tests) pass; tsc clean.

Change checklist

  • I have ensured that the CI Checks pass locally
  • I have removed any unnecessary logic
  • My code is well documented
  • I have signed my commits
  • My code follows the pattern of the application
  • I have self reviewed my code

@coodos

coodos commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@coderabbitai

coderabbitai Bot commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 8c24853e-65c1-451c-b71c-0a3ec0f41e9f
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-08T10:25:17.938652Z e2fa180 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e2fa180571

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +135 to +138
const ticket = await this.delegationGuard.beforeWrite(eName, write);
let result: R;
try {
result = await run();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make authority validation and the protected write atomic

Because beforeWrite and run execute in separate database transactions, the authority state can change between them. For example, a role creation can validate while its signer is a director, a concurrent board update can remove that director, and then the role creation can still land; similarly, a re-delegation can land after its parent is concurrently revoked. The relevant authority records need to be locked or revalidated in the same transaction that performs the write.

Useful? React with 👍 / 👎.

Comment on lines +172 to +174
`MATCH (g:DelegationGrant { claimToken: $token })
WHERE g.metaEnvelopeId IS NULL
DELETE g`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Release claimed grants after failed updates

For updates, claimGrant initializes metaEnvelopeId to previous.id, but this cleanup only deletes claims whose metaEnvelopeId is null. Consequently, if the subsequent database update fails, abortWrite leaves the claim behind and retrying the same signed update returns REPLAYED_GRANT even though the write never landed. Update claims should remain distinguishable as uncommitted until afterWrite binds them.

Useful? React with 👍 / 👎.

Comment on lines +94 to +95
const previous = write.id ? await this.load(write.id, eName) : null;
const next = { ontology: write.ontology, parsed: write.payload ?? {} };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the actual post-PATCH authority record

updateMetaEnvelopeById has PATCH semantics and retains fields omitted from the submitted payload, but the guard treats only write.payload as the complete next record and verifies the authorization against it. A signed partial Role, Delegation, or Shareholding update can therefore be accepted and stored alongside retained fields that are absent from the signed payload, leaving the stored authority record with an authorization that does not match its actual contents. Merge the existing record with the patch before signature and policy validation.

Useful? React with 👍 / 👎.

Comment on lines +1880 to +1886
const result = await verifySignature({
eName,
signature,
payload,
registryBaseUrl,
});
return result.valid;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject signers that have no bound verification keys

This verifier delegates to signature-validator, whose verifySignature returns { valid: true } when /whois supplies an empty key-binding-certificate list (infrastructure/signature-validator/src/index.ts:338-345). Thus a resolvable signer with no bound keys can authorize a company record using any nonempty signature, defeating the new write guard; this wrapper must fail closed when no key was actually used to verify the signature.

Useful? React with 👍 / 👎.

Comment on lines +384 to +390
if (!parent.mayRedelegate) {
throw new DelegationWriteError(
"NOT_AUTHORIZED",
"the parent does not allow re-delegation",
);
}
this.requireNarrowing(next, parent, parent.mayRedelegate, "parent");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Require a boolean before honoring mayRedelegate

Authority payloads are arbitrary JSON and are not validated against the ontology schema here, so a parent stored with mayRedelegate: "false" passes this truthiness check and is treated as allowing re-delegation. A delegate can then create child delegations despite the supplied value representing false; validate mayRedelegate as an actual boolean on every Role and Delegation write before using it in authorization decisions.

Useful? React with 👍 / 👎.

Comment on lines +672 to +676
await this.delegationGuard.assertNotGoverned(
context.eName,
id,
"Rolling back",
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the IMMUTABLE code in structured mutations

When this assertion rejects a governed record, the surrounding rollbackMetaEnvelope catch converts the DelegationWriteError to ROLLBACK_FAILED; removeMetaEnvelope similarly converts it to DELETE_FAILED. Clients of these structured mutations therefore cannot distinguish the intentional immutability policy from an operational database failure, unlike the create/update paths that preserve guard codes. Handle DelegationWriteError explicitly in both catches and return its IMMUTABLE code.

Useful? React with 👍 / 👎.

Comment on lines +528 to +532
private async load(id: string, eName: string): Promise<Existing | null> {
const found = await this.db.findMetaEnvelopeById(id, eName);
return found
? { id: found.id, ontology: found.ontology, parsed: found.parsed }
: null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Check pruned authority records before rollback

assertNotGoverned relies on this loader, but findMetaEnvelopeById only matches live MetaEnvelope nodes. A previously deleted governed Role or Delegation is labeled PrunedMetaEnvelope, so the assertion sees no record and rollbackMetaEnvelope can restore an old active authority version without signature, authorization, or replay checks. Load the pruned record or inspect the rollback target/history before allowing restoration.

Useful? React with 👍 / 👎.

Comment on lines +173 to +175
await createDelegationGrantConstraint(driver);
} catch (error) {
console.warn("Failed to create delegation grant constraint:", error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Fail startup when the grant constraint is unavailable

The single-use claim uses MERGE safely under concurrency only when the new composite uniqueness constraint exists, as the migration itself documents. If constraint creation fails because of permissions, incompatible Neo4j configuration, or existing duplicate data, this catch merely warns and starts the service anyway; concurrent uses of one signed payload can then create separate DelegationGrant nodes and both be accepted. Treat failure to establish this security constraint as fatal rather than running without replay protection.

Useful? React with 👍 / 👎.

@coodos

coodos commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Superseded: enforcement moves to verifiers; the eVault stays a plain store.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant