Skip to content

feat(AF-622): out-of-office reviewer delegation - #723

Merged
babltiga merged 13 commits into
mainfrom
feature/AF-622-reviewer-delegation
Aug 17, 2026
Merged

feat(AF-622): out-of-office reviewer delegation#723
babltiga merged 13 commits into
mainfrom
feature/AF-622-reviewer-delegation

Conversation

@babltiga

Copy link
Copy Markdown
Contributor

Implements out-of-office reviewer delegation from #622. This is PR 1 of 2 — idle-reviewer escalation and nudge reminders follow in a second PR, which will close the issue.

A reviewer names a delegate for a window. During it the delegate is an eligible approver everywhere the delegator was — query review, governed API requests, and grouped requests — and every decision records both identities plus the delegation that authorised it.

Safety properties, enforced in the service layer

  • A delegation never grants a permission. The Permission check runs before delegation is resolved, so it widens which requests an already-permitted reviewer may act on, never whether they may review.
  • The self-approval ban covers both identities. A delegate can never decide a request the delegator submitted. That delegator is dropped from the candidate set rather than the call being rejected, so a reviewer eligible in their own right keeps their authority.
  • Eligibility is per identity as a whole. A flow that checks several predicates must satisfy them all from the same ReviewCandidate — matching an approver rule as delegator A while matching the datasource-reviewer scope as delegator B would synthesize an identity nobody holds.
  • No transitivity. Resolution is one hop; A→B→C confers nothing on C. Enforced by construction (the lookup never traverses), because a creation-time check is defeated by creating the two rows in the other order.
  • One human, one vote. The per-stage UNIQUE (request, reviewer_id, stage) index stays keyed on the acting reviewer, so a delegate covering two absent approvers cannot satisfy min_approvals = 2 alone. A service guard covers what the index cannot see — the delegator voting personally and their delegate then voting for them, in either order.
  • Both parties must be active. Re-checked on every resolution, so an admin deactivation or SCIM deprovisioning takes effect immediately rather than waiting for a job.

Behaviour changes

Three, all deliberate and documented in docs/07-security.md:

  1. Grouped-request review now requires QUERY_REVIEW. GroupReviewController had no @PreAuthorize on any method and listPending had no eligibility filter, so any authenticated user could enumerate every pending bundle in their org. Landed as the branch's first, independently revertible commit — a user who could review bundles before and lacks the permission will now be rejected.
  2. API-request review now honours approver rules. It previously had no approver check at all: any holder of API_REQUEST_REVIEW could decide any pending request. Opt-in by configuration — a connector with no review plan, or a plan with no approver rules, behaves exactly as before, because treating "no plan" as "nobody is an approver" would make every un-planned connector unreviewable on upgrade.
  3. total_elements on the grouped-request queue is now an upper bound. Approver eligibility there is a union over member review plans and cannot be pushed into SQL, so filtering happens after paging — the same trade-off GET /reviews/pending already makes.

Schema

V142 creates review_delegations; V143 adds nullable on_behalf_of_user_id + delegation_id to review_decisions, api_review_decisions and group_review_decisions. Both are additive, no existing migration touched, no ALTER TYPE … ADD VALUE.

The table lives in core, not workflow as the issue suggested: all three review modules already depend on core.api, and QueryRequestRepository.findPendingForReviewer — which must filter on these rows — lives in core. A workflow-owned entity would still resolve in that JPQL and Spring Modulith would not catch it, since JPQL is a string rather than an import.

Docs updated

docs/03-data-model.md, docs/04-api-spec.md, docs/05-backend.md, docs/06-frontend.md, docs/07-security.md, docs/09-deployment.md, docs/12-roadmap.md, README.md, website/index.html, website/docs/configuration/review-workflows/index.html (+ the cfg-review-delegation anchor in both frontend/src/config/docs.ts and website/app.js), website/README.md, website/sitemap.xml.

Review notes

Four independent reviewers ran before this PR (af-verifier, af-reviewer, af-java-reviewer, af-frontend-reviewer). Every blocker was fixed in ee29b487; these are the surviving concerns a human reviewer should weigh.

Deferred by design

  • (af-reviewer, af-java-reviewer) The grouped-request queue resolves per-group eligibility in memory, and GroupReviewPlanResolver issues a lookup per member — a 20-row page of 5-member bundles is materially more queries than before. The alternative is a per-page plan cache; the correctness fix (the queue previously leaked every bundle in the org) was the priority here, and the shape is now identical to the query-review queue's.
  • (af-java-reviewer) apigov still hard-codes STAGE = 1, so an approver rule with stage > 1 on a connector's plan is unreachable. Pre-existing; now visible because approver rules are read at all. Worth its own issue.

Addressed after review

  • (af-java-reviewer) ReviewDelegationRepository.search's (:param is null or …) pattern had no integration test, so a Hibernate UUID parameter-inference failure would have surfaced only at runtime on the admin listing. Now covered by ReviewDelegationRepositoryIntegrationTest against real PostgreSQL, along with the half-open window boundary, the both-parties-active filter and the scope PG-enum round-trip.

Known gaps, called out in the API spec rather than papered over

  • The API-request and grouped-request queues honour delegations for eligibility but do not yet carry the delegated_for badge field; only /reviews/pending does. Likewise api_review_decisions / group_review_decisions persist the provenance columns but their read models don't expose them yet.

Not covered by e2e

  • Cross-user eligibility — a delegate seeing the delegator's queue and approving with on-behalf-of provenance — needs a datasource, a review plan naming the delegator and a third submitter. It is covered against real Postgres by the backend integration tests instead; the e2e spec seeds its own delegate and covers the delegation lifecycle and its refusals.

Adjacent defect found, deliberately not fixed here

  • Permission.REVIEW_OVERRIDE is documented as "always an eligible approver" and honoured in access, lifecycle, attestation, requestgroups and DefaultQueryCollaborationAccessService — but not in DefaultReviewService, so an admin not named in a review plan cannot approve a query. It sits on the exact method this PR modifies, but fixing it changes who may approve and is not part of this issue.

Refs #622

…ts queue

GroupReviewController had no @PreAuthorize on any method and
DefaultGroupReviewService.listPending had no eligibility filter, so any
authenticated user could enumerate every pending grouped request in their
org — name, description, risk and submitter included. Only the decision
path was guarded, and only by an approver-rule match.

Add PERM_QUERY_REVIEW on all three endpoints and in the service, and
filter the queue with the same predicate the decision guard uses, so the
queue can never list a group the caller would be rejected for.

Prerequisite for reviewer delegation (#622): the invariant that a
delegation never grants a permission the delegate lacks is unsatisfiable
while the flow gates on no permission at all.

BEHAVIOUR CHANGE: a user who could review grouped requests before and
does not hold QUERY_REVIEW will now be rejected. Because approver
eligibility is a union over member review plans and cannot be pushed into
SQL, the queue filters after paging, so total_elements is an upper bound
— the same trade-off GET /reviews/pending already makes.

Also documents the /me/review-delegations and /admin/review-delegations
endpoints ahead of their implementation, per CLAUDE.md.
Introduces review_delegations plus on-behalf-of provenance columns on the
three decision tables, and the core.api surface the review flows will use:
ReviewDelegationLookupService (hot path), ReviewDelegationService (CRUD),
DelegatedIdentity, and ReviewCandidate.

The table lives in core rather than workflow because all three review
modules already depend on core.api, and because findPendingForReviewer —
which must filter on these rows — lives in core. A workflow-owned entity
would still resolve in that JPQL, since JPQL is a string rather than an
import, so Modulith would not catch the coupling.

ReviewCandidate exists so eligibility is evaluated per identity as a
whole. A flow that checks several predicates must not satisfy one from
delegator A and another from delegator B; that would synthesize an
identity nobody actually holds.

Delegation resolution is non-transitive by construction: the lookup reads
the table once and never follows a delegator's own delegations. Enforcing
that on write would be defeated by creating the two rows in the other
order. Both parties' active flags and the window are re-checked at read
time against the injected Clock, never current_timestamp, so a
deactivation takes effect immediately without a cleanup job and tests can
control time.

Scope validation spans modules — an API_CONNECTOR scope resolves against
apigov, which already depends on core — so ReviewDelegationScopeResolver
inverts it: core collects implementations and each owning module supplies
its own. An unregistered kind fails closed.

No behaviour change yet; nothing consults the lookup service until the
per-module eligibility wiring lands.
Query review (workflow) and API-request review (apigov) now resolve the
caller's active delegations and evaluate eligibility per identity.

Eligibility is checked per ReviewCandidate as a whole, never by OR-ing the
predicates across identities: matching the approver rule as delegator A
while matching the datasource-reviewer scope as delegator B would
synthesize an identity neither of them holds.

The self-approval ban covers both identities. A delegator who submitted
the request is dropped from the candidate set rather than rejecting the
call outright, so a reviewer who is independently eligible keeps their own
authority.

One authority, one vote. The UNIQUE (request, reviewer_id, stage) index
keeps the acting human to a single decision, but cannot see that a
delegator already voted personally, or that a delegate already voted for
them — different reviewer_id. A service guard covers both orders.

The pending-review JPQL now takes collections and uses an exists subquery
instead of a join, dropping select distinct: the join fanned out one row
per matching approver rule, which is why the count query was
count(distinct q). The submitter exclusion stays scalar — widening it to
cover delegators would hide requests the reviewer is eligible for in their
own right, and that rule is per-identity.

apigov gains approver-based eligibility, which it had none of: any holder
of API_REQUEST_REVIEW could decide any pending request. It is opt-in by
configuration — a connector with no review plan, or a plan with no
approver rules, stays open to any permitted reviewer, because treating
'no plan' as 'nobody is an approver' would make every un-planned connector
unreviewable on upgrade. Its permission check also moves into the service,
since listPending is reachable from the dashboard module.

BEHAVIOUR CHANGE: on a connector whose review plan configures approver
rules, reviewers who do not match one can no longer decide its requests.

recordRejection/recordChangesRequested gained overloads rather than
default methods — a default method delegating to the proxied overload
bypasses @transactional and fails with 'No active transaction'.
Grouped-request review folds delegated identities into the union its
resolver builds across member plans. A scoped delegation covers a bundle
when its resource is one of the members — consistent with that union, and
never broader than what the delegator could do themselves.

Adds the self-service /me/review-delegations endpoints and a read-only
/admin/review-delegations listing behind QUERY_ADMIN, which is what lets
an auditor interpret an on_behalf_of entry in a decision trail.

Service-layer validation messages resolve through MessageSource rather
than being built in Java, with the 13 new keys added to all seven locale
files. The delegation cap is a config knob so the per-identity OR-tree the
API-review queue builds stays bounded.
Adds the out-of-office card on /profile, a Delegated tag on the review
queue, and on-behalf-of attribution in the approval timeline, so a
delegated decision never reads as though the delegator acted themselves.

Picking a delegate needed a new endpoint: the only user listing is gated
on USER_MANAGE, which most reviewers do not hold, so the picker would have
been empty for exactly the people the feature is for.
/me/review-delegations/candidates returns id, email and display name only
— no role, permission, or activity — and reviewers in an organization
already see each other's names on timelines and queues.

Error handling routes through apiErrorMessage so the server's localized
reason survives; the backend names the specific rule that failed, which is
more useful than a generic 'invalid delegation'.

37 locale keys across all seven files, and 13 new tests.
Covers the data model and its invariants, the eligibility rules and why
each is enforced where it is, the security properties an auditor cares
about, the profile card and queue tag, and the two behaviour changes this
release makes to grouped-request and API-request review.

The website gains an Out-of-office delegation section under Review
workflows, with its cfg-review-delegation anchor registered in both
frontend/src/config/docs.ts and website/app.js so the in-app 'View docs'
buttons resolve.

The new e2e spec covers the refusals that are cheap to assert against the
bootstrap admin — self-delegation, an inverted window, the caller never
appearing in their own candidate list — plus the profile card's empty
states and the admin listing. Cross-user eligibility needs a second seeded
reviewer, so it stays with the backend integration tests, which already
exercise it against real Postgres.
… limit

The form declared reason in its values type and sent it on submit, but
never rendered an input, so the field was unreachable and the backend's
@SiZe(max = 500) had no matching Form.Item rule — a validation-parity gap
in the direction that only shows up as a 400 from the server.
backend/docker-compose-dev.yml and the PG18 volume-path edits in
docs/09-deployment.md are an unrelated local fix that a broad `git add`
pulled in. Restored to main; they belong in their own change.
Blockers:
- ApiReviewerNotEligibleException had no @ExceptionHandler, so the headline
  new authorization check returned 500 with an ERROR-level log instead of
  the 403 its javadoc promises. Mapped in ApiGovExceptionHandler with an
  i18n key across all seven locales.
- API_CONNECTOR-scoped delegations could never be created: core owns the
  table but cannot reach the connector catalog, and no apigov resolver
  existed, so validateScope 422'd every well-formed request while the whole
  read side branched on a row the write side could not produce. Adds
  ApiConnectorDelegationScopeResolver, and the frontend picker now offers
  both kinds instead of hardcoding one.
- The one-authority-one-vote guard ran ahead of the idempotent-replay
  check and dropped the caller's OWN identity, so with min_approvals >= 2 a
  retry was rejected as ineligible rather than replaying — silently
  removing the documented wasIdempotentReplay contract, and via the missing
  handler surfacing as a 500. Decisions the acting user cast themselves are
  now excluded from the guard; both real cases it exists for still fire.
  Two tests were passing only on mock states a replay cannot occur in.
- '(on behalf of X)' was a hardcoded English string in the approval
  timeline.

Performance: the queue's actionability filter, delegated badge and stage
were three full recomputations of the same plan/decisions/routing/scope
lookups per row; they are now one pass returning the matched identity.
Delegator identities and the admin listing's user lookups are batched.
The apigov reach no longer filters to active connectors, which was hiding
in-flight requests on a connector deactivated after submission.

Also: the config knob moved to a @ConfigurationProperties record under
accessflow.core.review-delegation, revoke no longer audits a no-op,
delegated_for carries the email and display name the API spec promised,
findActiveDelegateIds is gone (no caller, and its javadoc claimed one),
and docs/04-api-spec.md's error table, revoke semantics, grouped-request
scoping and response shapes now match the code.

e2e: the new profile card's info Alert had silently defeated
profile-api-keys.spec.ts's page-wide role=alert assertion — scoped to the
API-keys card. reviews-delegation.spec.ts now seeds its own delegate via
the invitation helpers, so create/revoke is actually covered instead of
self-skipping.
revoke now reports whether it actually revoked, so the controller can skip
auditing a no-op. A Mockito mock returns false by default, which made the
test assert an audit row that the controller was correctly not writing.
Adds the no-op case explicitly.
The admin listing's search() uses the (:param is null or column = :param)
form, which can trip Hibernate's parameter-type inference on PostgreSQL for
UUID binds — a runtime-only failure that the mocked unit tests cannot see.
Also pins the half-open window boundary, the both-parties-active filter, the
scope PG enum round-trip, and what countOpenForDelegator ignores.

Drops the ReviewDelegationPage type, orphaned when the admin API module
function was removed.
@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Frontend Test Results

1 499 tests  +19   1 499 ✅ +19   4m 6s ⏱️ -58s
  193 suites + 2       0 💤 ± 0 
    1 files   ± 0       0 ❌ ± 0 

Results for commit 9b4c315. ± Comparison against base commit ab5970d.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Frontend Coverage (frontend)

Status Category Percentage Covered / Total
🟢 Lines 94.18% (🎯 90%) 2235 / 2373
🟢 Statements 92.3% (🎯 90%) 2484 / 2691
🟢 Functions 91.38% (🎯 90%) 679 / 743
🟢 Branches 84.39% (🎯 80%) 1395 / 1653
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
frontend/src/config/docs.ts 100% 100% 100% 100%
frontend/src/pages/queries/buildTimelineStages.ts 100% 93.61% 100% 100%
frontend/src/utils/enumLabels.ts 96.91% 100% 91.52% 96.89% 198, 206, 327, 473, 531
Generated in workflow #954 for commit 9b4c315 by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Backend Test Results

6 279 tests   6 279 ✅  19m 27s ⏱️
  756 suites      0 💤
  756 files        0 ❌

Results for commit 9b4c315.

♻️ This comment has been updated with latest results.

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Backend Code Coverage

Overall Project 93.19% -0.17% 🍏
Files changed 89.47% 🍏

File Coverage
ApiConnectorDelegationScopeResolver.java 100% 🍏
DatasourceDelegationScopeResolver.java 100% 🍏
QueryRequestRepository.java 100% 🍏
CreateReviewDelegationRequest.java 100% 🍏
DelegateCandidateResponse.java 100% 🍏
ReviewDelegationResponse.java 100% 🍏
ReviewDelegationPageResponse.java 100% 🍏
GroupReviewController.java 100% 🍏
AdminReviewDelegationController.java 100% 🍏
MeReviewDelegationController.java 100% 🍏
DelegationScopeKind.java 100% 🍏
ReviewDelegationView.java 100% 🍏
DelegatedIdentity.java 100% 🍏
ReviewDelegationNotFoundException.java 100% 🍏
ReviewDecisionSnapshot.java 100% 🍏
ReviewDelegateCandidate.java 100% 🍏
ReviewDelegationStatus.java 100% 🍏
RecordApprovalCommand.java 100% 🍏
CreateReviewDelegationCommand.java 100% 🍏
IllegalReviewDelegationException.java 100% 🍏
ReviewDelegationException.java 100% 🍏
QueryDetailView.java 100% 🍏
ReviewCandidate.java 100% 🍏
ReviewDelegationFilter.java 100% 🍏
AuditAction.java 100% 🍏
AuditResourceType.java 100% 🍏
ReviewService.java 100% 🍏
ApiReviewerNotEligibleException.java 100% 🍏
CorePropertiesConfiguration.java 100% 🍏
ReviewDelegationProperties.java 100% 🍏
DefaultReviewService.java 97.12% -0.17% 🍏
DefaultReviewDelegationLookupService.java 95.61% -4.39% 🍏
DefaultQueryRequestLookupService.java 94.38% -3.98% 🍏
DefaultGroupReviewService.java 93.75% -1.35% 🍏
DefaultReviewDelegationService.java 93.67% -6.33% 🍏
DefaultQueryRequestStateService.java 92.46% 🍏
GlobalExceptionHandler.java 86.04% -1.87% 🍏
QueryDetailResponse.java 85.85% -1.91% 🍏
PendingReviewItem.java 85.31% -14.69% 🍏
DefaultApiReviewService.java 82.18% -10.66% 🍏
ApiRequestSpecifications.java 80.51% -19.49% 🍏
UserQueryServiceImpl.java 74.03% -19.48% 🍏
ApiGovExceptionHandler.java 48.29% 🍏
RequestGroupSpecifications.java 5.83% 🍏

The profile-page test asserted the delegate's display name, but
inviteUserViaApi's no-roleId path posts a camelCase displayName that the
snake_case API ignores, so an invited user has none and the table falls
back to the email. The card, the delegation and the revoke flow all
rendered correctly — only the assertion was wrong.

Also confirms revocation by the row's action disappearing rather than by
matching 'Revoked' text, which is ambiguous once retries leave several
retired rows behind, and switches the Popconfirm to the primary-button
idiom the other specs use.
The coverage report showed ReviewDelegateCandidate and
DelegateCandidateResponse at 0% — the whole delegate-picker path shipped
untested, despite listDelegateCandidates carrying real logic: it excludes
the caller, drops deactivated members, and sorts by display name falling
back to email for invited users who have none (which would NPE a naive
comparator).

Also pins ApiReviewerNotEligibleException to 403. Without that mapping the
feature's own authorization check falls through to the global catch-all and
answers 500 with an ERROR-level log on every denial — the exact regression
the review caught, now guarded.
@babltiga
babltiga merged commit e628eff into main Aug 17, 2026
34 checks passed
@babltiga
babltiga deleted the feature/AF-622-reviewer-delegation branch August 17, 2026 07:16
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