v0.5.0 · Granular permissions, space-scoped roles, AI budget governance, and audit logging
Numen's RBAC system enables fine-grained team access control without external dependencies. Manage who can create, edit, and publish content; set AI generation budgets; and maintain a complete audit trail of sensitive actions.
- No vendor lock-in — Numen's own lightweight RBAC implementation
- Flat permission strings — easy to grep, compose, and document (
content.publish,ai.generate, etc.) - Space-scoped roles — users can be Editor in one space and Viewer in another
- AI governance — per-role budget limits, model access restrictions, and token scoping
- Audit logs — immutable records of all sensitive actions
- Built-in roles — Admin, Editor, Author, Viewer with sensible defaults
- Token scoping — API tokens inherit a subset of user permissions
# Get the role ID
curl https://yoursite.com/api/v1/roles \
-H "Authorization: Bearer YOUR_TOKEN"
# Assign the role
curl -X POST https://yoursite.com/api/v1/users/{userId}/roles \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role_id": "role-id-here"}'curl https://yoursite.com/api/v1/permissions \
-H "Authorization: Bearer YOUR_TOKEN"curl -X POST https://yoursite.com/api/v1/api-tokens \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub Actions Bot",
"abilities": ["content.read", "content.create", "pipeline.run"]
}'Every space includes four system roles (seeded on first migration). They're editable but not deletable.
| Role | Description | Key Permissions | AI Limits |
|---|---|---|---|
| Admin | Full system access | * (wildcard) |
Unlimited |
| Editor | Manage content & pipeline | content.*, pipeline.*, media.*, ai.generate, settings.personas |
100 gen/day, all models except Opus |
| Author | Create & submit content | content.create/read/update, pipeline.run, media.upload, ai.generate |
20 gen/day, Haiku only |
| Viewer | Read-only access | content.read, media.read |
No AI access |
New users start with no role. You must explicitly assign one.
Permissions follow a domain-based naming convention:
domain.action Examples: content.create, users.manage
domain.sub.action Examples: ai.model.opus, users.roles.assign
Fetch the complete list via the API:
GET /api/v1/permissionsOr see the Permission Taxonomy section below.
*grants all permissions (system admin)content.*grants all content permissions (content.create,content.read,content.delete, etc.)pipeline.*grants all pipeline permissions- Wildcard expansion happens at check-time — new permissions added in future releases automatically apply
When a user has multiple roles, their effective permissions are the union of all assigned roles:
User's assigned roles:
- Editor (in Space A)
- Author (globally)
Effective permissions = Editor ∪ Author
If the Editor and Author roles conflict, the most permissive setting wins.
| Permission | Description |
|---|---|
content.create |
Create new content entries |
content.read |
View draft and published content |
content.update |
Edit existing content |
content.delete |
Delete content |
content.publish |
Publish or unpublish content |
content.restore |
Restore deleted content |
| Permission | Description |
|---|---|
pipeline.run |
Trigger pipeline execution |
pipeline.approve |
Approve pending runs for publication |
pipeline.reject |
Reject pipeline output |
| Permission | Description |
|---|---|
media.upload |
Upload media assets |
media.delete |
Delete media assets |
media.organize |
Manage media folders and tags |
| Permission | Description |
|---|---|
users.manage |
Invite, edit, deactivate users |
users.roles.assign |
Assign and revoke roles |
users.invite |
Invite new users |
users.delete |
Delete user accounts |
| Permission | Description |
|---|---|
roles.manage |
Create, edit, and delete custom roles |
| Permission | Description |
|---|---|
spaces.manage |
Create and configure spaces |
spaces.delete |
Delete spaces |
| Permission | Description |
|---|---|
settings.general |
Modify system configuration |
settings.api_tokens |
Manage API tokens |
settings.personas |
Create and edit personas |
| Permission | Description |
|---|---|
audit.view |
Access audit logs |
| Permission | Description |
|---|---|
ai.generate |
Trigger AI text generation |
ai.image.generate |
Trigger AI image generation |
ai.budget.unlimited |
Bypass daily/monthly generation limits |
ai.model.haiku |
Use Haiku-tier models (cheap) |
ai.model.sonnet |
Use Sonnet-tier models (standard) |
ai.model.opus |
Use Opus-tier models (expensive) |
component.manage |
Register and update custom component types |
persona.view |
View persona configurations |
A user assigned a role without a space has that role everywhere:
{
"user_id": "user-123",
"role_id": "role-author",
"space_id": null ← null = global assignment
}The Viewer role is typically assigned globally to give read-only access across all spaces.
A user can have different roles in different spaces:
// User is Editor in Space A
{
"user_id": "user-456",
"role_id": "role-editor",
"space_id": "space-a-id"
}
// But only Viewer in Space B
{
"user_id": "user-456",
"role_id": "role-viewer",
"space_id": "space-b-id"
}When the user works in Space A, they have Editor permissions. In Space B, they only have Viewer permissions.
GET /api/v1/roles?space_id=space-123
Authorization: Bearer YOUR_TOKENReturns all roles (global + space-scoped) in that space.
Requires: roles.manage permission
POST /api/v1/roles
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"name": "Content Reviewer",
"slug": "content-reviewer",
"description": "Reviews and approves AI-generated content",
"space_id": "space-123", # Optional: leave null for global role
"permissions": [
"content.read",
"content.update",
"pipeline.approve",
"pipeline.reject"
],
"ai_limits": {
"daily_generations": 0,
"allowed_models": []
}
}Returns the created role object.
Requires: roles.manage permission
PUT /api/v1/roles/{roleId}
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"permissions": ["content.read", "content.update"],
"ai_limits": { ... }
}Requires: roles.manage permission
DELETE /api/v1/roles/{roleId}
Authorization: Bearer YOUR_TOKENSystem roles (is_system: true) cannot be deleted, only edited.
Requires: roles.manage permission
GET /api/v1/roles/{roleId}/users
Authorization: Bearer YOUR_TOKENRequires: roles.manage permission
POST /api/v1/users/{userId}/roles
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"role_id": "role-123",
"space_id": null # Optional: null = global assignment
}Requires: users.roles.assign permission
Security Note: You cannot assign a role with more permissions than you have. The system enforces principle of least privilege.
GET /api/v1/users/{userId}/roles
Authorization: Bearer YOUR_TOKENReturns all roles assigned to the user (space-scoped + global).
DELETE /api/v1/users/{userId}/roles/{roleId}
Authorization: Bearer YOUR_TOKENIf the user has multiple roles in a space, this removes one of them.
Requires: users.roles.assign permission
GET /api/v1/audit-logs?user_id=user-123&action=content.publish&from=2026-03-01T00:00:00Z&to=2026-03-07T23:59:59Z&per_page=50&page=1
Authorization: Bearer YOUR_TOKEN| Parameter | Type | Description |
|---|---|---|
user_id |
string | Filter by user |
action |
string | Filter by action (e.g., content.publish) |
resource_type |
string | Filter by resource model |
from |
ISO-8601 | Earliest timestamp |
to |
ISO-8601 | Latest timestamp |
per_page |
integer | Results per page (default: 50) |
page |
integer | Page number |
Response:
{
"data": [
{
"id": "018e1234...",
"user_id": "user-123",
"space_id": "space-456",
"action": "content.publish",
"resource_type": "App\\Models\\Content",
"resource_id": "content-789",
"metadata": {
"version": 3,
"scheduled_for": "2026-03-10T09:00:00Z"
},
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"created_at": "2026-03-07T12:34:56Z"
}
]
}Requires: audit.view permission
| Action | When Logged |
|---|---|
content.publish |
Content published |
content.delete |
Content deleted |
content.restore |
Content restored |
pipeline.run |
Pipeline execution starts |
pipeline.approve |
Human approves a run |
pipeline.reject |
Human rejects a run |
role.assign |
Role assigned to user |
role.revoke |
Role revoked from user |
ai.generation |
AI text generation completed |
ai.generation.failed |
AI generation failed |
ai.budget.exceeded |
User exceeds budget limit |
users.create |
New user invited |
users.delete |
User account deleted |
permission.denied |
Permission check failed |
GET /api/v1/permissions
Authorization: Bearer YOUR_TOKENResponse:
{
"data": {
"content": {
"content.create": "Create new content entries",
"content.read": "View draft and published content",
...
},
"users": {
"users.manage": "Manage user accounts",
...
},
...
}
}Use this endpoint to:
- Populate permission checkboxes in a role editor UI
- Validate permission strings before creating/updating roles
- Display permission descriptions to users
Requires: roles.manage permission (admin UI only) or auth:sanctum (to prevent discovery by anonymous users)
POST /api/v1/api-tokens
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json
{
"name": "CI/CD Bot",
"abilities": ["content.read", "content.create", "pipeline.run"]
}Key Point: The token can only have abilities that are a subset of your user's permissions. You cannot escalate privileges via token creation.
Token permissions use an intersection check:
User roles: [Editor, Author]
User permissions: [content.*, pipeline.*, media.*, ai.generate]
Token abilities: [content.read, content.create] ← subset
───────────────────────────────────────────────────────
Effective access: [content.read, content.create]
Both the user and the token must grant permission for an action to succeed.
Tokens support wildcard abilities:
{
"abilities": ["content.*", "pipeline.run"]
}Same rules apply:
*grants all abilitiescontent.*grants all content abilitiespipeline.*grants all pipeline abilities
curl -X POST https://yoursite.com/api/v1/content \
-H "Authorization: Bearer token_abc123xyz" \
-H "Content-Type: application/json" \
-d '{ ... }'Tokens don't auto-revoke, but you can revoke them via:
DELETE /api/v1/api-tokens/{tokenId}
Authorization: Bearer YOUR_TOKENEach role can have generation limits configured:
{
"daily_generations": 50,
"daily_image_generations": 10,
"monthly_cost_limit_usd": 100.00,
"allowed_models": ["claude-haiku-4-5", "claude-sonnet-4-6"],
"max_tokens_per_request": 4096,
"require_approval_above_cost_usd": 0.50
}When a user attempts generation:
- Effective limits are computed from all assigned roles (most permissive wins)
- System checks before the API call:
- Is the model in
allowed_models? - Have we hit
daily_generationstoday? - Would this exceed
monthly_cost_limit_usd?
- Is the model in
- If within limits → proceeds
- If above cost threshold → pauses and requires approval
Grant ai.budget.unlimited to bypass all numeric limits:
{
"permissions": ["ai.generate", "ai.budget.unlimited"]
}Edit app/Services/Authorization/PermissionRegistrar.php:
public function all(): array
{
return [
'content' => [
'content.create' => 'Create new content entries',
'content.read' => 'View draft and published content',
// Add your new permission here:
'content.bulk_edit' => 'Edit multiple content items at once',
],
// ... other domains
];
}Add an entry to the Permission Taxonomy section above.
use App\Services\AuthorizationService;
class ContentController extends Controller
{
public function bulkEdit(Request $request, AuthorizationService $authz)
{
$authz->authorize(auth()->user(), 'content.bulk_edit', $request->space_id);
// Proceed with bulk edit logic
}
}Route::put('/content/bulk', [ContentController::class, 'bulkEdit'])
->middleware('permission:content.bulk_edit');@can('content.bulk_edit')
<button>Bulk Edit</button>
@endcanUpdate built-in role seeders or let admins grant it via the role editor:
POST /api/v1/roles/{roleId}
{
"permissions": ["content.create", "content.read", "content.bulk_edit"]
}AuditLog::create([
'user_id' => auth()->user()->id,
'action' => 'content.bulk_edit',
'resource_type' => 'App\Models\Content',
'metadata' => ['count' => $count],
]);- Principle of Least Privilege — grant only required permissions
- Regular Audits — review
audit_logsweekly for suspicious patterns - Token Rotation — cycle personal access tokens quarterly
- Budget Monitoring — set realistic
ai_limits; alert on excess usage - Backup Logs — export important audit logs before 90-day prune
- Rate Limiting — combine RBAC with endpoint rate limits
- API Key Governance — revoke tokens when users leave the team
- Audit Log Access — grant
audit.viewsparingly; use for compliance only
- Check user has the required role assigned
- Verify role includes the required permission
- If using a token, verify token abilities include the permission
- Confirm role is assigned in the correct space (if space-scoped)
You're trying to assign a role with more permissions than you have. Have an Admin do it instead.
User hit daily_generations or monthly_cost_limit_usd. Wait until tomorrow or ask Admin to increase limit or grant ai.budget.unlimited.
Check action is in the logged actions list. Not all actions are audited — only sensitive ones.
# 1. Create the user (via admin panel)
# 2. Get the Author role ID
AUTHOR_ROLE_ID="018e1234-5678-7abc-def0-aaaaaaaaaaaa"
# 3. Assign the role
curl -X POST https://yoursite.com/api/v1/users/{userId}/roles \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"role_id": "'$AUTHOR_ROLE_ID'"}'# 1. Create a bot user account
# 2. Assign it a limited Author role (no publishing)
# 3. Create a token with minimal abilities
curl -X POST https://yoursite.com/api/v1/api-tokens \
-H "Authorization: Bearer BOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "GitHub Actions",
"abilities": ["content.read", "content.create"]
}'curl "https://yoursite.com/api/v1/audit-logs?user_id={userId}&from=2026-03-01T00:00:00Z" \
-H "Authorization: Bearer YOUR_TOKEN"- RBAC Architecture & Design — deep dive into system design
- Security Review — threat model and mitigations
- OpenAPI Specification — complete API reference