Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/approvals-viewer-can-act.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"@objectstack/spec": minor
"@objectstack/plugin-approvals": minor
---

feat(approvals): server-computed `viewer` capability for precise decision-action gating

`getRequest` / `listRequests` now attach a per-viewer block —
`viewer: { can_act, is_submitter }` — computed from the caller's context
(`ApprovalRequestRow.viewer`):

- `can_act` — the caller is a *current pending approver* (their user id is in the
request's resolved `pending_approvers` while it is still `pending`). This is
the same check the decision methods authorize with, so it already reflects
position/team/manager resolution — strictly more accurate than a client-side
identity guess.
- `is_submitter` — the caller submitted the request.

The declared decision actions on `sys_approval_request` now gate on it: approver
actions (approve/reject/reassign/send-back/request-info) use
`record.viewer.can_act`; submitter levers (remind/recall/resubmit) use
`record.viewer.is_submitter`. Previously approver actions only trimmed the
non-pending case, so a submitter viewing their own pending request saw buttons
they couldn't use (the backend 403'd); a position-addressed approver could be
wrongly hidden by the old client heuristic. Where `viewer` is absent (a row
surfaced outside a service read with a user context), the predicate fails closed.
27 changes: 27 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,33 @@ describe('ApprovalService (node era)', () => {
expect(await svc.getRequest('nope', SYS)).toBeNull();
});

// ── viewer capability (#3310) ───────────────────────────────────
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false });
});

it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); // → approved
const after = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
expect(after!.status).toBe('approved');
expect(after!.viewer!.can_act).toBe(false);
});

it('listRequests: attaches viewer to every row from the caller context', async () => {
await svc.openNodeRequest(openInput(['u9']), CTX);
const rows = await svc.listRequests({ status: 'pending' }, { userId: 'u9', tenantId: 't1' } as any);
expect(rows.length).toBeGreaterThan(0);
expect(rows.every(r => r.viewer != null)).toBe(true);
expect(rows[0].viewer!.can_act).toBe(true);
});

// ── recall ──────────────────────────────────────────────────────

it('recall: submitter withdraws a pending request', async () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/plugins/plugin-approvals/src/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2174,6 +2174,7 @@ export class ApprovalService implements IApprovalService {
const rows = await this.engine.find('sys_approval_request', findOpts);
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
await this.enrichRows(list);
this.attachViewers(list, context);
return list;
}

Expand Down Expand Up @@ -2221,6 +2222,7 @@ export class ApprovalService implements IApprovalService {
await this.enrichRows([row]);
await this.attachFlowSteps(row);
await this.attachDecisionProgress(row, rows[0]);
this.attachViewers([row], context);
return row;
}

Expand Down Expand Up @@ -2270,6 +2272,26 @@ export class ApprovalService implements IApprovalService {
} catch { /* display-only enrichment */ }
}

/**
* Attach the per-viewer capability block (#3310) from the caller's context.
* `can_act` mirrors the exact authorization the decision methods enforce — the
* caller's user id is in the resolved `pending_approvers` while the request is
* still `pending` (position/team/manager approvers are already resolved to
* concrete user ids at open time, so a plain membership test is faithful).
* `is_submitter` is a straight owner check. System/tokenless contexts get a
* both-false block. Cheap + synchronous — safe on list reads.
*/
private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
for (const row of rows) {
const pending = row.pending_approvers ?? [];
(row as any).viewer = {
can_act: row.status === 'pending' && !!uid && pending.includes(uid),
is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
};
}
}

/**
* Derive approval-step progress from the owning flow's graph (single-read
* enrichment only — list reads skip it). Walks from the start node
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,16 @@ describe('sys_approval_request declared actions', () => {
}
});

it('gates submitter-only levers on the current user; approver actions only on pending', () => {
it('gates on the server-computed viewer block (#3310): approver actions on can_act, submitter levers on is_submitter', () => {
for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
expect(vis(name)).toContain('record.submitter_id == ctx.user.id');
expect(vis(name)).toContain('record.viewer.is_submitter');
expect(vis(name)).not.toContain('can_act');
}
// Approver-side actions defer who-can-act to the service; they only trim the
// non-pending case in the UI.
for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
expect(vis(name)).toContain('record.status == "pending"');
expect(vis(name)).toContain('record.viewer.can_act');
// who-can-act is server-derived, never a client identity guess
expect(vis(name)).not.toContain('ctx.user.id');
expect(vis(name)).not.toContain('is_submitter');
}
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -236,8 +236,12 @@ export const SysApprovalRequest = ObjectSchema.create({
// (and their params) ship as metadata, not as hand-written buttons. Each
// targets the existing approvals REST route; `{id}` resolves from the row
// and `actorId` defaults to the caller server-side. The service remains the
// authority on who may act (pending-approver check) — `visible` only trims
// the obvious non-pending case.
// authority on who may act; `visible` gates on the server-computed
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
// (the caller is a current pending approver — same check the service
// authorizes a decision with, so position/team approvers resolve correctly),
// submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
// getRequest/listRequests; where it is absent the predicate fails closed.
actions: [
{
name: 'approval_approve',
Expand All @@ -253,7 +257,7 @@ export const SysApprovalRequest = ObjectSchema.create({
// string[]`; the decision route persists them on `sys_approval_action`.
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
],
visible: 'record.status == "pending"',
visible: 'record.viewer.can_act',
locations: ['record_section', 'list_item'],
successMessage: 'Approved.',
refreshAfter: true,
Expand All @@ -269,7 +273,7 @@ export const SysApprovalRequest = ObjectSchema.create({
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
],
visible: 'record.status == "pending"',
visible: 'record.viewer.can_act',
confirmText: 'Reject this request? A rejection is final for every approver.',
locations: ['record_section', 'list_item'],
successMessage: 'Rejected.',
Expand All @@ -291,16 +295,16 @@ export const SysApprovalRequest = ObjectSchema.create({
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
],
visible: 'record.status == "pending"',
visible: 'record.viewer.can_act',
locations: ['record_section'],
successMessage: 'Reassigned.',
refreshAfter: true,
},

// ── Approver secondary decisions ────────────────────────────────
// Send back for revision / request more info (ADR-0044). Both are approver
// actions on a pending request; the service is the authority on who may act,
// so `visible` only trims the non-pending case (matching approve/reject).
// actions, so `visible` gates on `record.viewer.can_act` (a current pending
// approver) — same as approve/reject. The service stays the authority.
{
name: 'approval_send_back',
label: 'Send back',
Expand All @@ -311,7 +315,7 @@ export const SysApprovalRequest = ObjectSchema.create({
params: [
{ name: 'comment', label: 'Reason', type: 'textarea', required: false },
],
visible: 'record.status == "pending"',
visible: 'record.viewer.can_act',
locations: ['record_section'],
successMessage: 'Sent back for revision.',
refreshAfter: true,
Expand All @@ -326,18 +330,18 @@ export const SysApprovalRequest = ObjectSchema.create({
params: [
{ name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
],
visible: 'record.status == "pending"',
visible: 'record.viewer.can_act',
locations: ['record_section'],
successMessage: 'Information requested.',
refreshAfter: true,
},

// ── Submitter continuity actions ────────────────────────────────
// Remind / recall (pending) and resubmit / recall (returned). These are the
// submitter's own levers, so `visible` gates on `submitter_id == ctx.user.id`
// the current user is exposed via the console's predicate scope. The
// service re-checks ownership; the predicate keeps a non-submitter from ever
// seeing a button they cannot use.
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
// (server-computed on the current viewer). The service re-checks ownership;
// the predicate keeps a non-submitter from ever seeing a button they cannot
// use.
{
name: 'approval_remind',
label: 'Send reminder',
Expand All @@ -348,7 +352,7 @@ export const SysApprovalRequest = ObjectSchema.create({
params: [
{ name: 'comment', label: 'Note', type: 'textarea', required: false },
],
visible: 'record.status == "pending" && record.submitter_id == ctx.user.id',
visible: 'record.status == "pending" && record.viewer.is_submitter',
locations: ['record_section'],
successMessage: 'Reminder sent.',
refreshAfter: true,
Expand All @@ -365,7 +369,7 @@ export const SysApprovalRequest = ObjectSchema.create({
],
// Recall applies while the request is live for the submitter — pending
// (withdraw) or returned (abandon the revision instead of resubmitting).
visible: '(record.status == "pending" || record.status == "returned") && record.submitter_id == ctx.user.id',
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
locations: ['record_section'],
successMessage: 'Recalled.',
Expand All @@ -381,7 +385,7 @@ export const SysApprovalRequest = ObjectSchema.create({
params: [
{ name: 'comment', label: 'What changed?', type: 'textarea', required: false },
],
visible: 'record.status == "returned" && record.submitter_id == ctx.user.id',
visible: 'record.status == "returned" && record.viewer.is_submitter',
locations: ['record_section'],
successMessage: 'Resubmitted.',
refreshAfter: true,
Expand Down
22 changes: 22 additions & 0 deletions packages/spec/src/contracts/approval-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,28 @@ export interface ApprovalRequestRow {
need: number;
groups?: Array<{ group: string; got: number; need: number; satisfied: boolean }>;
};

/**
* Server-computed capability for THE CURRENT VIEWER (#3310), attached by
* `getRequest` / `listRequests` from the caller's context. Lets a client gate
* decision actions precisely without re-deriving identity resolution:
* declared approver actions use `record.viewer.can_act`, submitter actions use
* `record.viewer.is_submitter`.
*
* - `can_act` — the caller is a *current pending approver* (their user id is in
* the request's resolved `pending_approvers` while it is still `pending`).
* This mirrors the exact check the service uses to authorize a decision, so
* it is strictly more accurate than a client-side identity guess (it already
* reflects position/team/manager resolution baked into `pending_approvers`).
* - `is_submitter` — the caller submitted the request.
*
* Absent when the row is surfaced outside a service read with a user context
* (e.g. a raw data-API grid); a `record.viewer.*` predicate then fails closed.
*/
viewer?: {
can_act: boolean;
is_submitter: boolean;
};
}

/** Kinds of entries on a request's audit trail. */
Expand Down
Loading