Skip to content

[P3] approvals: server-computed viewer capability on getRequest (precise approver gating for declared actions) #3310

Description

@os-zhuang

Goal

Gate the declared approver actions on sys_approval_request by whether the current viewer can actually act, computed server-side — instead of the coarse record.status == "pending" they use today.

Why

After objectui#2697, the inbox renders decision actions from the object's declared set via DeclaredActionsBar. The approver-side actions (approval_send_back / approval_request_info / approval_reassign) are gated only on:

visible: 'record.status == "pending"'

So a submitter viewing their own pending request also sees "Send back / Request info / Reassign" — clicking them just 403s (FORBIDDEN: actor is not a pending approver). The service is the authority so it's safe, but it's a UX wart: buttons that can't work.

The old hardcoded inbox gated these behind canApproveReject = identities.some(id => pending_approvers.has(id)), where identities was built client-side (buildApproverIdentities: user id + email + role:*). That's both duplicated client logic and incomplete — it never resolved position/team/department membership, so a position-addressed approver could be wrongly hidden.

Design — mirror decision_progress (server computes, client zero-logic)

getRequest(requestId, context) already has the caller's full SharingExecutionContext (id, roles, positions, permissions) and already attaches a per-request computed block via attachDecisionProgress (→ decision_progress, added in #3274). Add a sibling per-viewer block the same way:

  • New computed field on ApprovalRequestRow (contract packages/spec/src/contracts/approval-service.ts, next to decision_progress at ~L109):
    viewer?: { can_act: boolean; is_submitter: boolean };
    • can_act = is the caller a current pending approver, using the same resolution the service uses to authorize a decision (reuse expandApprovers / the pending-approver check — id + email + roles + positions + teams), so it's strictly more accurate than the old client heuristic.
    • is_submitter = submitter_id == context.userId (lets the frontend drop its own submitter_id == ctx.user.id comparisons too, for symmetry).
  • Compute it in getRequest only (the drawer's single-request read); no need to burden list endpoints.
  • Point the declared actions' visible at it (in sys-approval-request.object.ts):
    • approver actions: visible: 'record.status == "pending" && record.viewer.can_act'
    • submitter actions (remind/recall/resubmit): visible: '… && record.viewer.is_submitter' (equivalent to today, but consistent + server-sourced).
  • viewer is absent on list rows → the CEL must fail-closed gracefully (a record.viewer.can_act on an undefined viewer should hide, not throw). Confirm the CEL engine's null-nav behavior; if needed keep status == pending as the outer guard so list contexts stay predictable.

Files

  • packages/spec/src/contracts/approval-service.ts — add viewer? to ApprovalRequestRow.
  • packages/plugins/plugin-approvals/src/approval-service.ts — compute viewer in getRequest (reuse the pending-approver resolution; mirror attachDecisionProgress).
  • packages/plugins/plugin-approvals/src/sys-approval-request.object.ts — tighten the declared visible predicates.
  • Tests: service test that getRequest returns can_act:true for a pending approver (incl. a position/team-addressed one) and false for the submitter; can_act:false once finalized.
  • objectui: sync the ApprovalRequestRow contract type (apps/console/src/services/approvalsApi.ts) — no UI code change; the drawer already renders from declared visible. Optional: drop the now-redundant client buildApproverIdentities-based canApproveReject/canRecall gating if nothing else uses it.

Acceptance criteria

  • Submitter viewing their own pending request no longer sees Send back / Request info / Reassign.
  • A position/team-addressed approver does see them (fixes the old client-side blind spot).
  • Submitter still sees Remind / Recall; Resubmit only on returned.
  • viewer computed only on getRequest; list endpoints unaffected; CEL fails closed when viewer absent.
  • plugin-approvals suite green; browser-verify both viewpoints in the inbox drawer.

Refs

objectui#2697 (button retirement) · #3274 (decision_progress, the pattern to mirror) · framework#3300 (declared action set)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions