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
125 changes: 125 additions & 0 deletions backend/docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Credential Access Permission System

Granular, role-based access control for credential operations: define permission
levels, assign roles to wallets, enforce permission checks on routes, and audit
every permission change.

## Overview

The `PermissionsModule` (`src/permissions/`) provides:

- **A permission model** — granular action-level permissions (`CredentialPermission`)
and named roles (`CredentialRole`) with a static role → permission mapping.
- **Role-based grants** — assign a role to a wallet, globally or scoped to a single
credential resource, with an optional expiry.
- **Permission checks** — resolve a wallet's effective permissions and answer
`hasPermission(wallet, permission, resourceId?)`.
- **Declarative enforcement** — a `@RequirePermission(...)` decorator + `PermissionsGuard`
that other feature modules attach to their credential routes.
- **Audited changes** — every role grant and revocation is written to the
tamper-evident audit trail via the `AuditService`.

## Permission model

Permissions are granular actions. Roles bundle permissions; a wallet holds roles,
not raw permissions.

| Permission | Meaning |
| --------------------- | ------------------------------------------------ |
| `credential:read` | View credential metadata / encrypted references |
| `credential:verify` | Verify a credential / validate a zk proof |
| `credential:issue` | Issue new credentials |
| `credential:revoke` | Revoke an existing credential |
| `credential:share` | Share a credential or grant scoped access |
| `permission:manage` | Assign or revoke roles for other wallets |
| `audit:read` | Read the audit trail |

| Role | Permissions |
| ---------- | ------------------------------------------------------------------------------ |
| `viewer` | `read` |
| `verifier` | `read`, `verify` |
| `issuer` | `read`, `verify`, `issue`, `revoke`, `share` |
| `admin` | all of the above plus `permission:manage`, `audit:read` |

The mapping lives in `ROLE_PERMISSIONS` (`permission.model.ts`) — the single source
of truth. Changing a role's powers is a one-line edit, not a data migration.

## Data model

`RoleAssignment` (`role_assignments` table):

| Field | Description |
| ------------------ | --------------------------------------------------------------- |
| `id` | UUID primary key |
| `granteeAddress` | Wallet the role is granted to |
| `role` | `viewer` \| `verifier` \| `issuer` \| `admin` |
| `resourceId` | Credential resource id, or `*` (`GLOBAL_SCOPE`) for all |
| `grantedByAddress` | Wallet that granted the role |
| `isActive` | Grants are revoked by flipping this to `false`, preserving history |
| `expiresAt` | Optional expiry (`null` = never expires) |
| `grantedAt` | Timestamp |
| `updatedAt` | Timestamp |

A `(granteeAddress, resourceId, role)` triple is unique — re-granting reactivates
and refreshes the existing row instead of duplicating it.

## Scoping

An assignment is either **global** (`resourceId = '*'`) — applies to every
credential — or **resource-scoped** to one credential id. A permission check for a
specific resource is satisfied by a matching resource-scoped grant **or** any
global grant.

## Enforcing checks on routes

Other feature modules protect their credential handlers declaratively:

```ts
@Controller('credentials')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class CredentialsController {
@Post(':id/verify')
@RequirePermission(CredentialPermission.VERIFY) // scoped to the `id` route param
verify(...) { ... }

@Post()
@RequirePermission(CredentialPermission.ISSUE, { resourceIdParam: null }) // global check
issue(...) { ... }
}
```

`PermissionsGuard` reads the caller's wallet (`request.user.walletAddress`,
populated by `JwtAuthGuard`), resolves the optional target resource from the route
param, and denies with `403` when the permission is not held.

## API

All endpoints require a valid JWT. Role management additionally requires a wallet
listed in `ADMIN_WALLETS` (the root of trust that bootstraps roles).

| Method & path | Guard | Description |
| ------------------------------------------ | ---------- | --------------------------------------------- |
| `GET /permissions/catalog` | JWT | The static role → permission map |
| `GET /permissions/me?resourceId=` | JWT | Caller's effective roles & permissions |
| `POST /permissions/check` | JWT | Check a permission for the caller |
| `POST /permissions/roles` | JWT + admin | Assign a role |
| `DELETE /permissions/roles/:id` | JWT + admin | Revoke a role assignment |
| `GET /permissions/roles` | JWT + admin | List all assignments |
| `GET /permissions/roles/grantee/:address` | JWT + admin | List a wallet's assignments |

`POST /permissions/roles` body: `{ granteeAddress, role, resourceId?, expiresAt? }`
(`resourceId` defaults to global; `expiresAt` is an ISO-8601 timestamp).

## Auditing

Role grants and revocations call `AuditService.record` with the
`role_assigned` / `role_revoked` operation types, capturing the actor, target
resource, and the grant details (grantee, role, scope, expiry) in metadata. These
entries are hash-chained alongside all other credential operations — see
[AUDIT_LOGGING.md](./AUDIT_LOGGING.md).

## Configuration

| Variable | Default | Description |
| --------------- | ------- | -------------------------------------- |
| `ADMIN_WALLETS` | (empty) | Comma-separated admin wallet allowlist |
2 changes: 2 additions & 0 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { IndexerModule } from './indexer/indexer.module';
import { HealthModule } from './health/health.module';
import { AuditModule } from './audit/audit.module';
import { BackupModule } from './backup/backup.module';
import { PermissionsModule } from './permissions/permissions.module';

@Module({
imports: [
Expand Down Expand Up @@ -72,6 +73,7 @@ import { BackupModule } from './backup/backup.module';
HealthModule,
AuditModule,
BackupModule,
PermissionsModule,
],
providers: [
{
Expand Down
2 changes: 2 additions & 0 deletions backend/src/audit/audit-log.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export enum AuditOperation {
DELETED = 'deleted',
SHARED = 'shared',
ACCESSED = 'accessed',
ROLE_ASSIGNED = 'role_assigned',
ROLE_REVOKED = 'role_revoked',
}

/**
Expand Down
31 changes: 31 additions & 0 deletions backend/src/permissions/dto/assign-role.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {
IsString,
IsNotEmpty,
IsEnum,
IsOptional,
IsISO8601,
} from 'class-validator';
import { CredentialRole } from '../permission.model';

export class AssignRoleDto {
@IsString()
@IsNotEmpty()
granteeAddress: string;

@IsEnum(CredentialRole)
role: CredentialRole;

/**
* Credential resource the role is scoped to. Omit for a global (`*`)
* assignment that applies to every resource.
*/
@IsOptional()
@IsString()
@IsNotEmpty()
resourceId?: string;

/** Optional ISO-8601 expiry timestamp. Omit for a non-expiring grant. */
@IsOptional()
@IsISO8601()
expiresAt?: string;
}
16 changes: 16 additions & 0 deletions backend/src/permissions/dto/check-permission.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { IsEnum, IsOptional, IsString, IsNotEmpty } from 'class-validator';
import { CredentialPermission } from '../permission.model';

export class CheckPermissionDto {
@IsEnum(CredentialPermission)
permission: CredentialPermission;

/**
* Resource to check the permission against. Omit to check only globally
* scoped grants.
*/
@IsOptional()
@IsString()
@IsNotEmpty()
resourceId?: string;
}
89 changes: 89 additions & 0 deletions backend/src/permissions/permission.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Credential access permission model.
*
* The system is role-based: a wallet is assigned one or more {@link CredentialRole}s
* (optionally scoped to a single credential resource), and each role expands to a
* fixed set of granular {@link CredentialPermission}s. Access checks resolve the
* union of permissions granted by a wallet's active, unexpired role assignments.
*/

/**
* Granular, action-level permissions that can be required by a protected
* operation. These are the atoms an access check is expressed against.
*/
export enum CredentialPermission {
/** View credential metadata and encrypted payload references. */
READ = 'credential:read',
/** Verify a credential / validate a zero-knowledge proof. */
VERIFY = 'credential:verify',
/** Issue new credentials (reserved for certified authorities). */
ISSUE = 'credential:issue',
/** Revoke an existing credential. */
REVOKE = 'credential:revoke',
/** Share a credential or grant another party scoped access. */
SHARE = 'credential:share',
/** Assign or revoke roles for other wallets. */
MANAGE = 'permission:manage',
/** Read the audit trail. */
AUDIT_READ = 'audit:read',
}

/**
* Named roles a wallet can hold. Ordered loosely from least to most privileged.
*/
export enum CredentialRole {
/** Read-only access to credentials that have been shared with the wallet. */
VIEWER = 'viewer',
/** Can read and verify credentials — e.g. a venue or travel authority. */
VERIFIER = 'verifier',
/** A certified health authority that can issue, revoke and share credentials. */
ISSUER = 'issuer',
/** Full control, including managing other wallets' roles. */
ADMIN = 'admin',
}

/**
* Static mapping from each role to the set of permissions it grants. This is the
* single source of truth for role capabilities; changing a role's powers is a
* one-line edit here rather than a data migration.
*/
export const ROLE_PERMISSIONS: Readonly<
Record<CredentialRole, readonly CredentialPermission[]>
> = {
[CredentialRole.VIEWER]: [CredentialPermission.READ],
[CredentialRole.VERIFIER]: [
CredentialPermission.READ,
CredentialPermission.VERIFY,
],
[CredentialRole.ISSUER]: [
CredentialPermission.READ,
CredentialPermission.VERIFY,
CredentialPermission.ISSUE,
CredentialPermission.REVOKE,
CredentialPermission.SHARE,
],
[CredentialRole.ADMIN]: [
CredentialPermission.READ,
CredentialPermission.VERIFY,
CredentialPermission.ISSUE,
CredentialPermission.REVOKE,
CredentialPermission.SHARE,
CredentialPermission.MANAGE,
CredentialPermission.AUDIT_READ,
],
};

/**
* Expand a role into its concrete set of permissions.
*/
export function permissionsForRole(
role: CredentialRole,
): readonly CredentialPermission[] {
return ROLE_PERMISSIONS[role] ?? [];
}

/**
* Sentinel `resourceId` denoting a role that applies to every credential
* resource rather than a single one.
*/
export const GLOBAL_SCOPE = '*';
89 changes: 89 additions & 0 deletions backend/src/permissions/permissions.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { PermissionsService } from './permissions.service';
import { AssignRoleDto } from './dto/assign-role.dto';
import { CheckPermissionDto } from './dto/check-permission.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AdminGuard } from '../audit/guards/admin.guard';
import { ROLE_PERMISSIONS } from './permission.model';

/**
* REST surface for the credential access permission system.
*
* Role management (assign/revoke/list) is restricted to administrator wallets
* (`AdminGuard`), which form the root of trust that bootstraps roles. Callers
* can always inspect and check their own effective permissions.
*/
@Controller('permissions')
@UseGuards(JwtAuthGuard)
export class PermissionsController {
constructor(private readonly permissionsService: PermissionsService) {}

/** The static role → permission catalog. */
@Get('catalog')
catalog() {
return ROLE_PERMISSIONS;
}

/** The calling wallet's effective roles and permissions for a resource. */
@Get('me')
async me(@Request() req, @Query('resourceId') resourceId?: string) {
const walletAddress = req.user.walletAddress;
const [roles, permissions] = await Promise.all([
this.permissionsService.getEffectiveRoles(walletAddress, resourceId),
this.permissionsService.getEffectivePermissions(
walletAddress,
resourceId,
),
]);
return { walletAddress, resourceId: resourceId ?? null, roles, permissions };
}

/** Check whether the calling wallet holds a specific permission. */
@Post('check')
async check(@Body() dto: CheckPermissionDto, @Request() req) {
const allowed = await this.permissionsService.hasPermission(
req.user.walletAddress,
dto.permission,
dto.resourceId,
);
return { allowed };
}

@Post('roles')
@UseGuards(AdminGuard)
assignRole(@Body() dto: AssignRoleDto, @Request() req) {
return this.permissionsService.assignRole(
dto,
req.user.walletAddress,
req.ip,
);
}

@Delete('roles/:id')
@UseGuards(AdminGuard)
revokeRole(@Param('id') id: string, @Request() req) {
return this.permissionsService.revokeRole(id, req.user.walletAddress, req.ip);
}

@Get('roles')
@UseGuards(AdminGuard)
findAll() {
return this.permissionsService.findAll();
}

@Get('roles/grantee/:granteeAddress')
@UseGuards(AdminGuard)
findByGrantee(@Param('granteeAddress') granteeAddress: string) {
return this.permissionsService.findByGrantee(granteeAddress);
}
}
Loading
Loading