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