Skip to main content

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)

RouteMethodAction(s)
/api/v1/knowledgeGETknowledge:view
/api/v1/knowledgePOSTknowledge:create
/api/v1/knowledge/{id}PUTknowledge:update
/api/v1/knowledge/{id}DELETEknowledge:delete
/api/v1/agent-flowsGETbuilder:agent:view
/api/v1/agent-flowsPOSTbuilder:agent:create
/api/v1/agent-flows/{id}PUTbuilder:agent:update
/api/v1/agent-flows/{id}DELETEbuilder:agent:delete
/api/v1/channels/teamsGETchannel:teams:view
/api/v1/channels/teamsPOSTchannel:teams:create
/api/v1/channels/teams/{id}PUTchannel:teams:update
/api/v1/channels/teams/{id}DELETEchannel:teams:delete
/api/v1/channels/whatsappGETchannel:whatsapp:view
/api/v1/channels/whatsappPOSTchannel:whatsapp:create
/api/v1/channels/whatsapp/{id}PUTchannel:whatsapp:update
/api/v1/channels/whatsapp/{id}DELETEchannel:whatsapp:delete
/api/v1/chatGETchat:view
/api/v1/chat/sendPOSTchat:send
/api/v1/conversationsGETchat:view
/api/v1/analyticsGETanalytics:view
/api/v1/credentialsGETbuilder:agent:view
/api/v1/credentialsPOSTbuilder:agent:create

8. Implementation Order

Phase 1: Foundation

  1. Create packages/authorization/contract.json — extract from current actions.ts + policy.ts
  2. Configure Keycloak Protocol Mapper — so org_roles claim appears in JWT
  3. Update TokenUser in Core — extract org_roles from raw_claims

Phase 2: Core API Protection

  1. Build Python adapter (neuraflow_auth) with require_action() guard
  2. Install as local dependency in Core
  3. Add Depends(require_action(...)) to each Core endpoint
  4. Enforce organization_id scoping on all org-level queries

Phase 3: Console Migration

  1. Build TypeScript adapter that reads from contract.json
  2. Replace console/src/lib/permissions/actions.ts and policy.ts with shared package imports
  3. Verify all existing permission checks still work

Phase 4: Identity Service

  1. Build Java adapter with PolicyResolver and PermissionEvaluator
  2. Replace manual role checks in Identity controllers with @PreAuthorize + permission evaluator

Phase 5: Organization Data Access Enforcement

  1. Add org membership validation — reject requests where user has no role in the target org
  2. Add resource ownership checks — verify resources belong to the requested org
  3. Audit log for authorization failures

9. Security Considerations

  • Additive model: sys_permissions UNION org_permissions — no permission subtraction
  • No implicit inheritance: administration:user:update does NOT grant administration: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_roles claim; consider limiting to active org only if token grows too large