mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
feat(tenancy): implement workspace isolation
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import types
|
||||
import typing
|
||||
|
||||
from .context import RequestContext
|
||||
|
||||
|
||||
class WorkspaceRole(enum.StrEnum):
|
||||
OWNER = 'owner'
|
||||
ADMIN = 'admin'
|
||||
DEVELOPER = 'developer'
|
||||
OPERATOR = 'operator'
|
||||
VIEWER = 'viewer'
|
||||
|
||||
|
||||
class Permission(enum.StrEnum):
|
||||
WORKSPACE_VIEW = 'workspace.view'
|
||||
WORKSPACE_UPDATE = 'workspace.update'
|
||||
WORKSPACE_DELETE = 'workspace.delete'
|
||||
OWNER_TRANSFER = 'owner.transfer'
|
||||
MEMBER_VIEW = 'member.view'
|
||||
MEMBER_INVITE = 'member.invite'
|
||||
MEMBER_UPDATE_ROLE = 'member.update_role'
|
||||
MEMBER_REMOVE = 'member.remove'
|
||||
RESOURCE_VIEW = 'resource.view'
|
||||
RESOURCE_MANAGE = 'resource.manage'
|
||||
RUNTIME_OPERATE = 'runtime.operate'
|
||||
PROVIDER_SECRET_MANAGE = 'provider_secret.manage'
|
||||
API_KEY_MANAGE = 'api_key.manage'
|
||||
AUDIT_VIEW = 'audit.view'
|
||||
DATA_EXPORT = 'data.export'
|
||||
BILLING_LINK_MANAGE = 'billing_link.manage'
|
||||
|
||||
|
||||
_VIEW_PERMISSIONS = {
|
||||
Permission.WORKSPACE_VIEW,
|
||||
Permission.MEMBER_VIEW,
|
||||
Permission.RESOURCE_VIEW,
|
||||
}
|
||||
|
||||
_ROLE_PERMISSIONS: typing.Final = types.MappingProxyType(
|
||||
{
|
||||
WorkspaceRole.OWNER: frozenset(Permission),
|
||||
WorkspaceRole.ADMIN: frozenset(
|
||||
permission
|
||||
for permission in Permission
|
||||
if permission
|
||||
not in {
|
||||
Permission.WORKSPACE_DELETE,
|
||||
Permission.OWNER_TRANSFER,
|
||||
Permission.BILLING_LINK_MANAGE,
|
||||
}
|
||||
),
|
||||
WorkspaceRole.DEVELOPER: frozenset(
|
||||
_VIEW_PERMISSIONS
|
||||
| {
|
||||
Permission.RESOURCE_MANAGE,
|
||||
Permission.RUNTIME_OPERATE,
|
||||
Permission.PROVIDER_SECRET_MANAGE,
|
||||
}
|
||||
),
|
||||
WorkspaceRole.OPERATOR: frozenset(_VIEW_PERMISSIONS | {Permission.RUNTIME_OPERATE}),
|
||||
WorkspaceRole.VIEWER: frozenset(_VIEW_PERMISSIONS),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationError(Exception):
|
||||
"""Base class for errors that map to an HTTP authorization response."""
|
||||
|
||||
status_code = 403
|
||||
error_code = 'forbidden'
|
||||
|
||||
|
||||
class WorkspaceRequiredError(AuthorizationError):
|
||||
status_code = 400
|
||||
error_code = 'workspace_required'
|
||||
|
||||
|
||||
class PermissionDeniedError(AuthorizationError):
|
||||
error_code = 'permission_denied'
|
||||
|
||||
def __init__(self, permission: str) -> None:
|
||||
super().__init__(f'Missing Workspace permission: {permission}')
|
||||
self.permission = permission
|
||||
|
||||
|
||||
class EditionLimitError(AuthorizationError):
|
||||
error_code = 'edition_limit'
|
||||
|
||||
|
||||
def permissions_for_role(role: str | WorkspaceRole) -> frozenset[str]:
|
||||
"""Return the canonical fixed permissions for a Workspace role."""
|
||||
|
||||
try:
|
||||
parsed_role = WorkspaceRole(role)
|
||||
except ValueError:
|
||||
return frozenset()
|
||||
return frozenset(permission.value for permission in _ROLE_PERMISSIONS[parsed_role])
|
||||
|
||||
|
||||
def has_permission(ctx: RequestContext, permission: str | Permission) -> bool:
|
||||
"""Return whether the context contains one effective permission."""
|
||||
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
return permission_value in ctx.workspace.permissions
|
||||
|
||||
|
||||
def require_permission(ctx: RequestContext, permission: str | Permission) -> None:
|
||||
"""Raise a stable authorization error when a permission is missing."""
|
||||
|
||||
permission_value = permission.value if isinstance(permission, Permission) else permission
|
||||
if not has_permission(ctx, permission_value):
|
||||
raise PermissionDeniedError(permission_value)
|
||||
Reference in New Issue
Block a user