Lumen Docs

Grants

Endpoints for managing project access grants. A grant is one row in project_grants targeting exactly one of: user, group, department.

POST /projects/:projectId/grants

Create or update a grant (upserts on compound unique key). Requires full tier on the project.

Request:

{
  "targetType": "user" | "group" | "department",
  "targetId": "<uuid>",
  "tier": "use" | "edit" | "full"
}

Response 201 (created) or 200 (updated):

{
  "grant": {
    "id": "uuid",
    "projectId": "uuid",
    "userId": "uuid" | null,
    "groupId": "uuid" | null,
    "departmentId": "uuid" | null,
    "tier": "use",
    "grantedById": "<caller-id>",
    "createdAt": "...",
    "updatedAt": "...",
    "user": {...} | null,
    "group": {...} | null,
    "department": {...} | null
  },
  "action": "created" | "updated"
}

Upserts via compound unique indexes:

  • (projectId, userId) for user grants
  • (projectId, groupId) for group grants
  • (projectId, departmentId) for department grants

If you POST the same target again with a different tier, it updates the existing grant (no 409).

Errors:

  • 404 target_not_found — the user/group/department doesn't exist
  • 403 — caller doesn't have full tier on the project

GET /projects/:projectId/grants

List all grants on a project. Requires use tier.

Response 200:

{
  "grants": [
    {
      "id": "...", "tier": "use",
      "user": {"id":"...", "name":"...", "email":"..."},
      "group": null,
      "department": null
    },
    {
      "id": "...", "tier": "edit",
      "user": null,
      "group": {"id":"...", "name":"Design", "department": {"name":"..."}},
      "department": null
    }
  ]
}

DELETE /projects/:projectId/grants/:grantId

Revoke a grant. Requires full tier.

Response 200:

{ "success": true, "id": "uuid" }

GET /grants/by-user/:userId

Admin-only (admin, superadmin). List every grant involving a user (direct, via their groups, via their department).

Response 200:

{
  "direct": [{"projectId": "...", "tier": "...", "project": {...}}],
  "viaGroup": [{"projectId": "...", "tier": "...", "group": {...}, "project": {...}}],
  "viaDepartment": [{"projectId": "...", "tier": "...", "department": {...}, "project": {...}}]
}

Useful for an "access audit" screen for a specific user.

Polymorphic integrity

A grant has a CHECK constraint ensuring exactly one of (userId, groupId, departmentId) is set:

CHECK (
  (user_id IS NOT NULL)::int +
  (group_id IS NOT NULL)::int +
  (department_id IS NOT NULL)::int = 1
)

The API validates this before calling Prisma, but the constraint guarantees DB-level integrity even if a bug slips through.

Audit trail

Every grant create/update/delete writes to audit_log:

{
  "action": "grant_created" | "grant_updated" | "grant_deleted",
  "actorId": "<caller-id>",
  "projectId": "uuid",
  "targetType": "user" | "group" | "department",
  "targetId": "uuid",
  "metadata": { "tier": "...", "previousTier": "..." }
}

Query via /audit-log endpoint (admin only).

Client usage example

// Grant a user edit access
await api.fetch(`/projects/${projectId}/grants`, {
  method: "POST",
  body: JSON.stringify({
    targetType: "user",
    targetId: user.id,
    tier: "edit",
  }),
});

// Revoke
await api.fetch(`/projects/${projectId}/grants/${grantId}`, {
  method: "DELETE",
});

The GrantAccessModal component (components/org/grant-access-modal.tsx) wraps this pattern for the admin UI — target picker + tier picker + submit.