Authorization System Design
Problem
Permissions are defined only in the console (TypeScript), but Core (Python) and Identity (Java) are blind to them. Routes are protected by role only (sys_admin/sys_user), not by granular actions like knowledge:create or channel:teams:delete.
Architecture: JSON Contract + Language Adapters
packages/
authorization/
contract.json <- Single source of truth (actions, roles, mappings)
ts/ <- Console adapter (replaces current actions.ts/policy.ts)
src/index.ts
package.json
python/ <- Core adapter (FastAPI guards)
neuraflow_auth/
__init__.py
actions.py <- Generated from contract.json
policy.py <- Role->permission resolution
guard.py <- FastAPI Depends() decorators
pyproject.toml
java/ <- Identity adapter (Spring Security)
src/.../authorization/
Actions.java
Policy.java
PermissionEvaluator.java
1. The Contract (contract.json)
Single source of truth for all services. Every service reads this file to resolve permissions.
{
"version": "1.0",
"sys_roles": ["sys_admin", "sys_user"],
"org_roles": ["org_owner", "org_admin", "org_editor", "org_member", "org_guest"],
"actions": {
"DASHBOARD_VIEW": "dashboard:view",
"ANALYTICS_VIEW": "analytics:view",
"BUILDER_AGENT_VIEW": "builder:agent:view",
"BUILDER_AGENT_CREATE": "builder:agent:create",
"BUILDER_AGENT_UPDATE": "builder:agent:update",
"BUILDER_AGENT_DELETE": "builder:agent:delete",
"BUILDER_AGENT_EXECUTE": "builder:agent:execute",
"BUILDER_WORKFLOW_VIEW": "builder:workflow:view",
"BUILDER_WORKFLOW_CREATE":"builder:workflow:create",
"BUILDER_WORKFLOW_UPDATE":"builder:workflow:update",
"BUILDER_WORKFLOW_DELETE":"builder:workflow:delete",
"KNOWLEDGE_VIEW": "knowledge:view",
"KNOWLEDGE_CREATE": "knowledge:create",
"KNOWLEDGE_UPDATE": "knowledge:update",
"KNOWLEDGE_DELETE": "knowledge:delete",
"CHAT_VIEW": "chat:view",
"CHAT_SEND": "chat:send",
"CHAT_READ": "chat:read",
"CHANNEL_TEAMS_VIEW": "channel:teams:view",
"CHANNEL_TEAMS_CREATE": "channel:teams:create",
"CHANNEL_TEAMS_UPDATE": "channel:teams:update",
"CHANNEL_TEAMS_DELETE": "channel:teams:delete",
"...": "..."
},
"sys_permissions": {
"sys_admin": [
"dashboard:view",
"analytics:view",
"administration:user:view",
"administration:user:create",
"administration:user:update",
"administration:user:update:role",
"administration:user:update:password",
"administration:user:update:email-verify",
"administration:user:update:status",
"administration:user:delete",
"administration:org:view",
"administration:org:create",
"administration:impersonate:user",
"system:config:view",
"system:config:update",
"system:teams:view",
"system:teams:create",
"system:teams:update",
"system:teams:delete"
],
"sys_user": [
"dashboard:view",
"analytics:view"
]
},
"org_permissions": {
"org_owner": [
"administration:user:view", "administration:org:view",
"builder:agent:view", "builder:agent:create", "builder:agent:update",
"builder:agent:delete", "builder:agent:execute",
"builder:workflow:view", "builder:workflow:create",
"builder:workflow:update", "builder:workflow:delete",
"channel:teams:view", "channel:teams:create", "channel:teams:update", "channel:teams:delete",
"channel:whatsapp:view", "channel:whatsapp:create", "channel:whatsapp:update", "channel:whatsapp:delete",
"channel:telegram:view", "channel:telegram:create", "channel:telegram:update", "channel:telegram:delete",
"channel:chat-widget:view", "channel:chat-widget:create", "channel:chat-widget:update", "channel:chat-widget:delete",
"channel:slack:view", "channel:slack:create", "channel:slack:update", "channel:slack:delete",
"channel:sms:view", "channel:sms:create", "channel:sms:update", "channel:sms:delete",
"channel:email:view", "channel:email:create", "channel:email:update", "channel:email:delete",
"channel:voice:view", "channel:voice:create", "channel:voice:update", "channel:voice:delete",
"knowledge:view", "knowledge:create", "knowledge:update", "knowledge:delete",
"marketing:sms:view", "marketing:email:view",
"chat:view", "chat:send", "chat:read",
"org:member:view", "org:member:create:invite", "org:member:update:role", "org:member:delete",
"org:billing:view", "org:billing:update",
"org:settings:view", "org:settings:update", "org:settings:delete",
"org:impersonation:view", "org:impersonation:create", "org:impersonation:delete",
"analytics:view"
],
"org_admin": [
"Same as org_owner minus: org:billing:update, org:settings:delete"
],
"org_editor": [
"View + create/update (no delete except knowledge)",
"No org management, no impersonation create/delete"
],
"org_member": [
"View-only + execute + chat",
"No create/update/delete"
],
"org_guest": [
"chat:read",
"analytics:view"
]
},
"authority_rules": {
"administration:user:update": ["org_owner", "org_admin"],
"administration:user:update:role": ["org_owner", "org_admin"],
"administration:user:update:status": ["org_owner", "org_admin"],
"administration:user:update:email-verify": ["org_owner", "org_admin"],
"administration:user:delete": ["org_owner", "org_admin"]
}
}
2. Org Role in JWT (Keycloak Protocol Mapper)
Current state: Core only gets realm_access.roles (sys roles) from the JWT. It has no way to know the user's org role.
Solution: Add a Keycloak Protocol Mapper to include org roles as a custom JWT claim.
JWT payload after adding mapper
{
"sub": "user-uuid",
"email": "user@example.com",
"realm_access": {
"roles": ["sys_user"]
},
"org_roles": {
"org-uuid-1": "org_owner",
"org-uuid-2": "org_member"
}
}
This avoids any network hop from Core to Identity at request time. The org roles are embedded directly in the access token.
Keycloak Configuration
- Mapper type: Script Mapper or User Attribute Mapper
- Token claim name:
org_roles - Claim JSON type: JSON
- Source: User attributes matching
org_role_*prefix
3. Python Adapter (Core API)
Package: neuraflow_auth
Installed as a local dependency in Core's pyproject.toml:
[project]
dependencies = [
"neuraflow-auth @ file:///${PROJECT_ROOT}/packages/authorization/python"
]
guard.py — FastAPI Dependency Decorators
from typing import Annotated
from uuid import UUID
from fastapi import Depends, HTTPException, Query, status
from app.api.deps import get_current_user
from app.core.security import TokenUser
from neuraflow_auth.policy import has_permission
def require_action(*actions: str):
"""
FastAPI dependency that checks permission actions.
Extracts org_role from the JWT org_roles claim for the given organization_id,
then verifies the user has all required actions.
"""
async def checker(
current_user: Annotated[TokenUser, Depends(get_current_user)],
organization_id: UUID = Query(...),
) -> TokenUser:
sys_role = current_user.role
org_role = current_user.raw_claims.get("org_roles", {}).get(str(organization_id))
for action in actions:
if not has_permission(action, sys_role, org_role):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {action}",
)
return current_user
return checker
def require_sys_action(*actions: str):
"""
FastAPI dependency for system-level actions (no org context needed).
Used for administration routes.
"""
async def checker(
current_user: Annotated[TokenUser, Depends(get_current_user)],
) -> TokenUser:
sys_role = current_user.role
for action in actions:
if not has_permission(action, sys_role, org_role=None):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission: {action}",
)
return current_user
return checker
policy.py — Permission Resolution
import json
from functools import lru_cache
from pathlib import Path
CONTRACT_PATH = Path(__file__).parent.parent.parent / "contract.json"
@lru_cache(maxsize=1)
def _load_contract() -> dict:
with open(CONTRACT_PATH) as f:
return json.load(f)
@lru_cache(maxsize=64)
def _resolve_permissions(sys_role: str | None, org_role: str | None) -> frozenset[str]:
contract = _load_contract()
perms = set()
if sys_role and sys_role in contract["sys_permissions"]:
perms.update(contract["sys_permissions"][sys_role])
if org_role and org_role in contract["org_permissions"]:
perms.update(contract["org_permissions"][org_role])
return frozenset(perms)
def has_permission(action: str, sys_role: str | None, org_role: str | None) -> bool:
if not sys_role:
return False
return action in _resolve_permissions(sys_role, org_role)
Usage in Routes
# core/app/api/v1/knowledge_base.py
from neuraflow_auth.guard import require_action
@router.get("/")
async def list_knowledge_bases(
current_user: Annotated[TokenUser, Depends(require_action("knowledge:view"))],
organization_id: UUID = Query(...),
db: AsyncSession = Depends(get_db),
):
# User verified to have knowledge:view for this org
...
@router.post("/")
async def create_knowledge_base(
current_user: Annotated[TokenUser, Depends(require_action("knowledge:create"))],
organization_id: UUID = Query(...),
...
):
...
@router.delete("/{kb_id}")
async def delete_knowledge_base(
current_user: Annotated[TokenUser, Depends(require_action("knowledge:delete"))],
organization_id: UUID = Query(...),
...
):
...
4. Java Adapter (Identity Service)
PermissionEvaluator.java
@Component
public class PermissionEvaluator {
private final PolicyResolver policyResolver;
public boolean hasAction(Authentication auth, UUID orgId, String action) {
JwtAuthenticationToken jwt = (JwtAuthenticationToken) auth;
String sysRole = extractSysRole(jwt);
String orgRole = extractOrgRole(jwt, orgId);
return policyResolver.hasPermission(action, sysRole, orgRole);
}
}
Usage in Controllers
@PreAuthorize("@permissionEvaluator.hasAction(authentication, #orgId, 'org:member:delete')")
@DeleteMapping("/organizations/{orgId}/members/{userId}")
public ResponseEntity<Void> removeMember(
@PathVariable UUID orgId,
@PathVariable UUID userId
) { ... }
5. TypeScript Adapter (Console)
Replace current console/src/lib/permissions/actions.ts and policy.ts with imports from the shared package.
packages/authorization/ts/src/index.ts
import contract from '../../contract.json'
export const Action = contract.actions
export type ActionType = (typeof contract.actions)[keyof typeof contract.actions]
const _permCache = new Map<string, Set<string>>()
export function hasPermission(
action: string,
sysRole: string | null,
orgRole: string | null,
): boolean {
if (!sysRole) return false
const key = `${sysRole}|${orgRole ?? ''}`
let perms = _permCache.get(key)
if (!perms) {
perms = new Set(contract.sys_permissions[sysRole] ?? [])
if (orgRole) {
for (const p of contract.org_permissions[orgRole] ?? []) perms.add(p)
}
_permCache.set(key, perms)
}
return perms.has(action)
}
Console package.json
{
"dependencies": {
"@neuraflow/authorization": "file:../packages/authorization/ts"
}
}
6. Request Flow
Console Core API Keycloak
| | |
| GET /knowledge?org_id=X | |
| Authorization: Bearer <JWT> | |
|----------------------------->| |
| | 1. Decode JWT (existing) |
| | 2. Extract sys_role from |
| | realm_access.roles |
| | 3. Extract org_role from |
| | org_roles[org_id] claim |
| | 4. Check: has_permission( |
| | "knowledge:view", |
| | sys_role, org_role) |
| | 5. If no -> 403 |
| | If yes -> query data |
|<-----------------------------| scoped to org_id |
7. Route-to-Action Mapping (Core API)
| Route | Method | Action(s) |
|---|---|---|
/api/v1/knowledge | GET | knowledge:view |
/api/v1/knowledge | POST | knowledge:create |
/api/v1/knowledge/{id} | PUT | knowledge:update |
/api/v1/knowledge/{id} | DELETE | knowledge:delete |
/api/v1/agent-flows | GET | builder:agent:view |
/api/v1/agent-flows | POST | builder:agent:create |
/api/v1/agent-flows/{id} | PUT | builder:agent:update |
/api/v1/agent-flows/{id} | DELETE | builder:agent:delete |
/api/v1/channels/teams | GET | channel:teams:view |
/api/v1/channels/teams | POST | channel:teams:create |
/api/v1/channels/teams/{id} | PUT | channel:teams:update |
/api/v1/channels/teams/{id} | DELETE | channel:teams:delete |
/api/v1/channels/whatsapp | GET | channel:whatsapp:view |
/api/v1/channels/whatsapp | POST | channel:whatsapp:create |
/api/v1/channels/whatsapp/{id} | PUT | channel:whatsapp:update |
/api/v1/channels/whatsapp/{id} | DELETE | channel:whatsapp:delete |
/api/v1/chat | GET | chat:view |
/api/v1/chat/send | POST | chat:send |
/api/v1/conversations | GET | chat:view |
/api/v1/analytics | GET | analytics:view |
/api/v1/credentials | GET | builder:agent:view |
/api/v1/credentials | POST | builder:agent:create |
8. Implementation Order
Phase 1: Foundation
- Create
packages/authorization/contract.json— extract from currentactions.ts+policy.ts - Configure Keycloak Protocol Mapper — so
org_rolesclaim appears in JWT - Update
TokenUserin Core — extractorg_rolesfromraw_claims
Phase 2: Core API Protection
- Build Python adapter (
neuraflow_auth) withrequire_action()guard - Install as local dependency in Core
- Add
Depends(require_action(...))to each Core endpoint - Enforce
organization_idscoping on all org-level queries
Phase 3: Console Migration
- Build TypeScript adapter that reads from
contract.json - Replace
console/src/lib/permissions/actions.tsandpolicy.tswith shared package imports - Verify all existing permission checks still work
Phase 4: Identity Service
- Build Java adapter with
PolicyResolverandPermissionEvaluator - Replace manual role checks in Identity controllers with
@PreAuthorize+ permission evaluator
Phase 5: Organization Data Access Enforcement
- Add org membership validation — reject requests where user has no role in the target org
- Add resource ownership checks — verify resources belong to the requested org
- Audit log for authorization failures
9. Security Considerations
- Additive model:
sys_permissions UNION org_permissions— no permission subtraction - No implicit inheritance:
administration:user:updatedoes NOT grantadministration:user:update:role - Authority rules: Some actions require both the additive permission AND a specific org role
- Org scoping: Every org-scoped query MUST filter by
organization_id— never return cross-org data - Fail closed: Missing org role or unknown action = denied
- Token size: Monitor JWT size after adding
org_rolesclaim; consider limiting to active org only if token grows too large