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:
404target_not_found— the user/group/department doesn't exist403— caller doesn't havefulltier 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.