+
+
+
{t('tenantAppUserManagement.permissionModal.effectiveTitle')}
{t('tenantAppUserManagement.permissionModal.effectiveDescription')}
+
navigate(`/tenant/apps/${appId}/rbac-mappings`)}>{t('tenantAppUserManagement.permissionModal.manageMappings')}
+
+ {effectiveGrants && !effectiveGrants.eligible ?
{t('tenantAppUserManagement.permissionModal.ineligible', { reason: effectiveGrants.denial_reason || 'unknown' })}
:
+
+
{t('tenantAppUserManagement.permissionModal.effectiveRoles', { count: effectiveGrants?.roles.length || 0 })}
{effectiveGrants?.roles.map(role =>
{role.name} {role.code}
{role.sources.map((source, index) =>
{grantSourceLabel(source)})}
)}{!effectiveGrants?.roles.length &&
{t('tenantAppUserManagement.permissionModal.noRoles')}}
+
{t('tenantAppUserManagement.permissionModal.effectivePermissions', { count: effectiveGrants?.permissions.length || 0 })}
{effectiveGrants?.permissions.map(permission =>
{permission.name} {permission.code}
{permission.sources.map((source, index) =>
{grantSourceLabel(source)}{source.via_role_code ? ` · ${source.via_role_code}` : ''})}
)}{!effectiveGrants?.permissions.length &&
{t('tenantAppUserManagement.permissionModal.noPermissions')}}
+
}
+
{/* : */}
{/* */}
diff --git a/basaltpass-frontend/src/routes/tenant/appRoutes.tsx b/basaltpass-frontend/src/routes/tenant/appRoutes.tsx
index b17d2e21..d5f764ac 100644
--- a/basaltpass-frontend/src/routes/tenant/appRoutes.tsx
+++ b/basaltpass-frontend/src/routes/tenant/appRoutes.tsx
@@ -8,6 +8,7 @@ import AppUserManagement from '@pages/tenant/app/AppUserManagement'
import AppRoleManagement from '@pages/tenant/app/AppRoleManagement'
import AppPermissionManagement from '@pages/tenant/app/AppPermissionManagement'
import RBACManifests from '@pages/tenant/app/RBACManifests'
+import AppGrantMappings from '@pages/tenant/app/AppGrantMappings'
import CreateApp from '@pages/admin/app/CreateApp'
import CrossAppTrustManagement from '@pages/tenant/security/CrossAppTrustManagement'
import { withTenant } from '@/routes/helpers'
@@ -23,6 +24,7 @@ export function TenantAppRoutes() {
)} />
)} />
)} />
+ )} />
)} />
)} />
)} />
diff --git a/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts b/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts
new file mode 100644
index 00000000..8b7707f2
--- /dev/null
+++ b/basaltpass-frontend/src/shared/api/tenant/appGrantMapping.ts
@@ -0,0 +1,119 @@
+import client from '../client'
+
+export type GrantSourceType = 'membership_role' | 'tenant_role' | 'tenant_permission'
+export type GrantTargetType = 'app_role' | 'app_permission'
+
+export interface GrantEndpoint {
+ type: GrantSourceType | GrantTargetType
+ id?: number
+ code: string
+ name: string
+}
+
+export interface AppGrantMapping {
+ id: number
+ tenant_id: number
+ app_id: number
+ source: GrantEndpoint
+ target: GrantEndpoint
+ enabled: boolean
+ valid_from?: string
+ valid_until?: string
+ affected_user_count: number
+ created_by: number
+ updated_by: number
+ created_at: string
+ updated_at: string
+}
+
+export interface AppGrantMappingInput {
+ source_type: GrantSourceType
+ source_id: number
+ source_code: string
+ target_type: GrantTargetType
+ target_id: number
+ enabled: boolean
+ valid_from?: string
+ valid_until?: string
+}
+
+export interface AppGrantMappingOptions {
+ membership_roles: GrantEndpoint[]
+ tenant_roles: GrantEndpoint[]
+ tenant_permissions: GrantEndpoint[]
+ app_roles: GrantEndpoint[]
+ app_permissions: GrantEndpoint[]
+}
+
+export interface GrantSource {
+ type: 'explicit' | 'tenant_mapping'
+ assignment_id?: number
+ mapping_id?: number
+ source_type?: GrantSourceType
+ source_id?: number
+ source_code?: string
+ via_role_code?: string
+}
+
+export interface EffectiveRole {
+ id: number
+ code: string
+ name: string
+ description: string
+ app_id: number
+ permissions: Array<{ id: number; code: string; name: string }>
+ sources: GrantSource[]
+}
+
+export interface EffectivePermission {
+ id: number
+ code: string
+ name: string
+ description: string
+ category: string
+ app_id: number
+ sources: GrantSource[]
+}
+
+export interface EffectiveGrants {
+ eligible: boolean
+ denial_reason?: string
+ roles: EffectiveRole[]
+ permissions: EffectivePermission[]
+}
+
+export const appGrantMappingApi = {
+ async list(appId: string) {
+ const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/mappings`)
+ return (response.data?.data?.mappings || []) as AppGrantMapping[]
+ },
+
+ async options(appId: string) {
+ const response = await client.get(`/api/v1/tenant/apps/${appId}/rbac/mappings/options`)
+ return response.data.data as AppGrantMappingOptions
+ },
+
+ async preview(appId: string, input: AppGrantMappingInput) {
+ const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/mappings/preview`, input)
+ return Number(response.data?.data?.affected_user_count || 0)
+ },
+
+ async create(appId: string, input: AppGrantMappingInput) {
+ const response = await client.post(`/api/v1/tenant/apps/${appId}/rbac/mappings`, input)
+ return response.data.data as AppGrantMapping
+ },
+
+ async update(appId: string, mappingId: number, input: AppGrantMappingInput) {
+ const response = await client.put(`/api/v1/tenant/apps/${appId}/rbac/mappings/${mappingId}`, input)
+ return response.data.data as AppGrantMapping
+ },
+
+ async remove(appId: string, mappingId: number) {
+ await client.delete(`/api/v1/tenant/apps/${appId}/rbac/mappings/${mappingId}`)
+ },
+
+ async effectiveGrants(appId: string, userId: string) {
+ const response = await client.get(`/api/v1/tenant/apps/${appId}/users/${userId}/effective-grants`)
+ return response.data.data as EffectiveGrants
+ },
+}
diff --git a/basaltpass-frontend/src/shared/i18n/messages/en.ts b/basaltpass-frontend/src/shared/i18n/messages/en.ts
index f5d027b1..71c871f0 100644
--- a/basaltpass-frontend/src/shared/i18n/messages/en.ts
+++ b/basaltpass-frontend/src/shared/i18n/messages/en.ts
@@ -2226,6 +2226,14 @@ const en = {
grantSelectedPermissions: 'Grant Selected Permissions ({{count}})',
assignRoles: 'Assign Roles',
assignSelectedRoles: 'Assign Selected Roles ({{count}})',
+ effectiveTitle: 'Effective authorization',
+ effectiveDescription: 'Read-only union of explicit assignments and current tenant mappings.',
+ effectiveRoles: 'Effective roles ({{count}})',
+ effectivePermissions: 'Effective permissions ({{count}})',
+ sourceExplicit: 'Explicit assignment',
+ sourceMapping: '{{source}} · mapping #{{mapping}}',
+ manageMappings: 'Manage mappings',
+ ineligible: 'No runtime grants are returned because the user is not eligible: {{reason}}',
},
success: {
permissionGranted: 'Permission granted successfully',
@@ -3607,6 +3615,11 @@ const en = {
description: 'Review app-submitted RBAC manifests',
badge: 'Review & Rollback',
},
+ grantMapping: {
+ title: 'Tenant → App Mapping',
+ description: 'Map tenant roles and permissions to app grants',
+ badge: 'Dynamic grants',
+ },
},
},
quickStart: {
@@ -3648,6 +3661,26 @@ const en = {
copyFailed: 'Copy failed:',
},
},
+ tenantAppGrantMappings: {
+ title: 'Tenant → App Grant Mappings',
+ description: 'Automatically derive app roles and permissions from current tenant access without creating user assignment records.',
+ back: 'Back to app',
+ dynamicNotice: 'Mappings are evaluated on every authorization query. Tenant role changes, expiry, disabling, and deletion take effect immediately.',
+ empty: 'No mapping policy has been configured for this app.',
+ affected: '{{count}} authorized users affected',
+ validity: 'Valid from {{from}} until {{until}}',
+ sourceTypes: { membership_role: 'Tenant membership role', tenant_role: 'Tenant RBAC role', tenant_permission: 'Effective tenant permission' },
+ targetTypes: { app_role: 'App role', app_permission: 'App permission' },
+ status: { enabled: 'Enabled', disabled: 'Disabled' },
+ actions: { create: 'Create mapping', edit: 'Edit', delete: 'Delete', enable: 'Enable', disable: 'Disable', preview: 'Preview impact', save: 'Save', cancel: 'Cancel' },
+ editor: {
+ createTitle: 'Create grant mapping', editTitle: 'Edit grant mapping', sourceType: 'Tenant source type', source: 'Tenant source',
+ targetType: 'App target type', target: 'App target', select: 'Select…', validFrom: 'Valid from (optional)', validUntil: 'Valid until (optional)',
+ enabled: 'Enable this mapping', previewResult: '{{count}} currently authorized app users match this policy.',
+ },
+ confirm: { delete: 'Delete the mapping from {{source}} to {{target}}? Inherited access will disappear immediately.' },
+ errors: { load: 'Failed to load grant mappings', preview: 'Failed to preview mapping impact', save: 'Failed to save grant mapping', delete: 'Failed to delete grant mapping', validity: 'Valid until must be later than valid from.' },
+ },
tenantRBACManifests: {
title: 'Automatic RBAC Import',
description: 'Review and publish RBAC-only manifests submitted by {{app}}',
@@ -3664,7 +3697,7 @@ const en = {
revision: 'App revision {{revision}}',
blocked: 'Blocked',
blockedTitle: 'This manifest cannot be published yet',
- blockedDescription: 'It removes a role or permission currently assigned to users. Remove the assignment manually or submit a compatible manifest.',
+ blockedDescription: 'It removes a role or permission referenced by user assignments or Tenant → App mappings. Remove the reference or submit a compatible manifest.',
noChanges: 'This manifest does not change the effective RBAC configuration.',
permissions: 'permissions',
roles: 'roles',
@@ -3674,13 +3707,13 @@ const en = {
permissionsAdded: 'Permissions added', permissionsUpdated: 'Permissions updated', permissionsRemoved: 'Permissions removed',
rolesAdded: 'Roles added', rolesUpdated: 'Roles updated', rolesRemoved: 'Roles removed',
rolePermissionsAdded: 'Role grants added', rolePermissionsRemoved: 'Role grants removed',
- assignedRolesAffected: 'Assigned roles affected', removalBlocks: 'User-assignment blockers',
+ assignedRolesAffected: 'Assigned or mapped roles affected', removalBlocks: 'Removal blockers',
},
revisions: {
title: 'Published revision history', empty: 'No published revision yet', active: 'Active',
baseline: 'Baseline', manifest: 'Manifest', rollback: 'Rollback',
safetyTitle: 'Rollback safety',
- safetyDescription: 'Rollback creates a new immutable revision. It is refused if it would delete a role or direct permission currently assigned to users.',
+ safetyDescription: 'Rollback creates a new immutable revision. It is refused if it would delete a role or permission referenced by user assignments or Tenant → App mappings.',
},
actions: { approve: 'Approve and publish', reject: 'Reject', rollback: 'Rollback to this revision' },
confirm: {
@@ -3694,7 +3727,7 @@ const en = {
},
errors: {
load: 'Failed to load RBAC manifests', approve: 'Failed to approve the manifest', reject: 'Failed to reject the manifest',
- rollback: 'Failed to roll back the revision', blocked: 'This manifest would remove RBAC entities assigned to users and cannot be published.',
+ rollback: 'Failed to roll back the revision', blocked: 'This manifest would remove referenced RBAC entities and cannot be published.',
},
},
adminDashboard: {
diff --git a/basaltpass-frontend/src/shared/i18n/messages/zh.ts b/basaltpass-frontend/src/shared/i18n/messages/zh.ts
index 2aa0162b..3756a198 100644
--- a/basaltpass-frontend/src/shared/i18n/messages/zh.ts
+++ b/basaltpass-frontend/src/shared/i18n/messages/zh.ts
@@ -2226,6 +2226,14 @@ const en = {
grantSelectedPermissions: 'Grant Selected Permissions ({{count}})',
assignRoles: 'Assign Roles',
assignSelectedRoles: 'Assign Selected Roles ({{count}})',
+ effectiveTitle: '当前有效授权',
+ effectiveDescription: '只读展示显式分配与 Tenant 动态映射合并后的结果。',
+ effectiveRoles: '有效角色({{count}})',
+ effectivePermissions: '有效权限({{count}})',
+ sourceExplicit: '显式分配',
+ sourceMapping: '{{source}} · 映射 #{{mapping}}',
+ manageMappings: '管理映射',
+ ineligible: '用户当前不符合运行时授权条件,因此不返回任何权限:{{reason}}',
},
success: {
permissionGranted: 'Permission granted successfully',
@@ -3607,6 +3615,11 @@ const en = {
description: '审核应用自动提交的 RBAC 配置',
badge: '审批与回滚',
},
+ grantMapping: {
+ title: 'Tenant → App 映射',
+ description: '将租户角色和权限映射为应用授权',
+ badge: '动态授权',
+ },
},
},
quickStart: {
@@ -3648,6 +3661,26 @@ const en = {
copyFailed: 'Copy failed:',
},
},
+ tenantAppGrantMappings: {
+ title: 'Tenant → App 授权映射',
+ description: '根据用户当前的租户角色和权限动态获得应用角色或权限,不创建用户继承分配记录。',
+ back: '返回应用',
+ dynamicNotice: '映射会在每次授权查询时动态计算;租户角色变更、过期、停用或删除会立即生效。',
+ empty: '该应用尚未配置任何授权映射。',
+ affected: '当前影响 {{count}} 个已授权用户',
+ validity: '生效时间:{{from}} 至 {{until}}',
+ sourceTypes: { membership_role: '租户成员身份', tenant_role: 'Tenant RBAC 角色', tenant_permission: '有效 Tenant 权限' },
+ targetTypes: { app_role: '应用角色', app_permission: '应用权限' },
+ status: { enabled: '已启用', disabled: '已停用' },
+ actions: { create: '新建映射', edit: '编辑', delete: '删除', enable: '启用', disable: '停用', preview: '预览影响', save: '保存', cancel: '取消' },
+ editor: {
+ createTitle: '新建授权映射', editTitle: '编辑授权映射', sourceType: '租户来源类型', source: '租户来源',
+ targetType: '应用目标类型', target: '应用目标', select: '请选择…', validFrom: '开始时间(可选)', validUntil: '结束时间(可选)',
+ enabled: '启用该映射', previewResult: '当前有 {{count}} 个已授权应用用户符合该策略。',
+ },
+ confirm: { delete: '确定删除 {{source}} 到 {{target}} 的映射吗?继承授权将立即消失。' },
+ errors: { load: '加载授权映射失败', preview: '预览映射影响失败', save: '保存授权映射失败', delete: '删除授权映射失败', validity: '结束时间必须晚于开始时间。' },
+ },
tenantRBACManifests: {
title: 'RBAC 自动导入',
description: '审核并发布 {{app}} 自动提交的纯 RBAC 配置',
@@ -3664,7 +3697,7 @@ const en = {
revision: '应用版本 {{revision}}',
blocked: '存在阻断',
blockedTitle: '当前配置暂时无法发布',
- blockedDescription: '它尝试删除已分配给用户的角色或权限。请先手动调整用户授权,或让应用提交兼容配置。',
+ blockedDescription: '它尝试删除被用户授权或 Tenant → App 映射引用的角色或权限。请先移除引用,或让应用提交兼容配置。',
noChanges: '该配置不会改变当前生效的 RBAC。',
permissions: '个权限',
roles: '个角色',
@@ -3674,13 +3707,13 @@ const en = {
permissionsAdded: '新增权限', permissionsUpdated: '更新权限', permissionsRemoved: '删除权限',
rolesAdded: '新增角色', rolesUpdated: '更新角色', rolesRemoved: '删除角色',
rolePermissionsAdded: '新增角色授权', rolePermissionsRemoved: '移除角色授权',
- assignedRolesAffected: '受影响的已分配角色', removalBlocks: '用户授权阻断项',
+ assignedRolesAffected: '受影响的已分配或映射角色', removalBlocks: '删除阻断项',
},
revisions: {
title: '已发布版本历史', empty: '尚无已发布版本', active: '当前生效',
baseline: '初始基线', manifest: '配置发布', rollback: '回滚版本',
safetyTitle: '回滚安全保护',
- safetyDescription: '回滚会创建新的不可变版本;如果需要删除已分配给用户的角色或直接权限,回滚将被拒绝。',
+ safetyDescription: '回滚会创建新的不可变版本;如果需要删除被用户授权或 Tenant → App 映射引用的角色或权限,回滚将被拒绝。',
},
actions: { approve: '批准并发布', reject: '拒绝', rollback: '回滚到此版本' },
confirm: {
@@ -3694,7 +3727,7 @@ const en = {
},
errors: {
load: '加载 RBAC 配置失败', approve: '批准配置失败', reject: '拒绝配置失败',
- rollback: '回滚版本失败', blocked: '该配置会删除已分配给用户的角色或权限,因此不能发布。',
+ rollback: '回滚版本失败', blocked: '该配置会删除仍被引用的角色或权限,因此不能发布。',
},
},
adminDashboard: {
diff --git a/scripts/test-re-services.py b/scripts/test-re-services.py
index ec166943..c547ada2 100644
--- a/scripts/test-re-services.py
+++ b/scripts/test-re-services.py
@@ -30,14 +30,16 @@ def no_redirect_open(opener, request):
raise
-def json_request(url, *, method="GET", data=None, token=None, expected=200):
+def json_request(url, *, method="GET", data=None, token=None, expected=200, headers=None):
body = None if data is None else json.dumps(data).encode()
- headers = {"Accept": "application/json"}
+ request_headers = {"Accept": "application/json"}
if data is not None:
- headers["Content-Type"] = "application/json"
+ request_headers["Content-Type"] = "application/json"
if token:
- headers["Authorization"] = f"Bearer {token}"
- request = urllib.request.Request(url, data=body, headers=headers, method=method)
+ request_headers["Authorization"] = f"Bearer {token}"
+ if headers:
+ request_headers.update(headers)
+ request = urllib.request.Request(url, data=body, headers=request_headers, method=method)
try:
response = urllib.request.urlopen(request, timeout=20)
status = response.status
diff --git a/scripts/test-tenant-app-mappings.py b/scripts/test-tenant-app-mappings.py
new file mode 100644
index 00000000..3438e9e1
--- /dev/null
+++ b/scripts/test-tenant-app-mappings.py
@@ -0,0 +1,241 @@
+#!/usr/bin/env python3
+"""Live tenant-to-app grant mapping checks against the three demo services.
+
+The test temporarily removes the normal user's explicit app role, grants the
+same role through a tenant membership mapping, exercises the real OAuth/S2S
+path, proves revocation, and restores the original assignments in ``finally``.
+
+Required environment variables:
+ BP_ADMIN_EMAIL, BP_ADMIN_PASSWORD, BP_USER_EMAIL, BP_USER_PASSWORD
+
+Optional environment variables:
+ BP_TENANT_ID (default 1), BP_USER_ID (default 2), BP_BASE_URL
+"""
+
+import json
+import os
+import runpy
+from pathlib import Path
+
+
+HELPERS = runpy.run_path(str(Path(__file__).with_name("test-re-services.py")))
+BP_BASE = HELPERS["BP_BASE"]
+bp_login = HELPERS["bp_login"]
+check_service = HELPERS["check_service"]
+json_request = HELPERS["json_request"]
+oauth_login = HELPERS["oauth_login"]
+required = HELPERS["required"]
+
+TENANT_ID = int(os.getenv("BP_TENANT_ID", "1"))
+USER_ID = int(os.getenv("BP_USER_ID", "2"))
+SERVICES = (
+ {"name": "relock", "app_id": int(os.getenv("BP_RELOCK_APP_ID", "8")), "role": "relock.user", "backend": "http://localhost:8108", "protected": "/v1/clock/alarms"},
+ {"name": "recal", "app_id": int(os.getenv("BP_RECAL_APP_ID", "9")), "role": "recal.user", "backend": "http://localhost:8110", "protected": "/v1/calendar/events"},
+ {"name": "renote", "app_id": int(os.getenv("BP_RENOTE_APP_ID", "10")), "role": "renote.user", "backend": "http://localhost:8109", "protected": "/v1/notes"},
+)
+
+
+def tenant_console_token(user_token):
+ authorized = json_request(
+ f"{BP_BASE}/api/v1/auth/console/authorize",
+ method="POST",
+ data={"target": "tenant", "tenant_id": TENANT_ID},
+ token=user_token,
+ headers={"X-Auth-Scope": "user"},
+ )
+ exchanged = json_request(
+ f"{BP_BASE}/api/v1/auth/console/exchange",
+ method="POST",
+ data={"code": authorized["code"]},
+ )
+ if exchanged.get("scope") != "tenant" or not exchanged.get("access_token"):
+ raise AssertionError(f"invalid tenant console exchange: {exchanged}")
+ return exchanged["access_token"]
+
+
+def tenant_request(token, path, **kwargs):
+ headers = dict(kwargs.pop("headers", {}) or {})
+ headers["X-Auth-Scope"] = "tenant"
+ return json_request(f"{BP_BASE}/api/v1/tenant{path}", token=token, headers=headers, **kwargs)
+
+
+def effective_role(grants, code):
+ return next((role for role in grants.get("roles", []) if role.get("code") == code), None)
+
+
+def prepare_mapping(token, service):
+ app_id = service["app_id"]
+ role_code = service["role"]
+ roles = tenant_request(token, f"/apps/{app_id}/roles").get("roles", [])
+ role = next((item for item in roles if item.get("code") == role_code), None)
+ if not role:
+ raise AssertionError(f"{service['name']}: app role {role_code} is missing")
+
+ mappings = tenant_request(token, f"/apps/{app_id}/rbac/mappings").get("data", {}).get("mappings", [])
+ duplicate = next(
+ (
+ item for item in mappings
+ if item.get("source", {}).get("type") == "membership_role"
+ and item.get("source", {}).get("code") == "member"
+ and item.get("target", {}).get("type") == "app_role"
+ and item.get("target", {}).get("id") == role["id"]
+ ),
+ None,
+ )
+ if duplicate:
+ raise AssertionError(
+ f"{service['name']}: refusing to mutate pre-existing mapping {duplicate['id']}"
+ )
+
+ direct = tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles")
+ was_explicit = any(item.get("id") == role["id"] for item in direct.get("roles", []))
+ mapping_input = {
+ "source_type": "membership_role",
+ "source_id": 0,
+ "source_code": "member",
+ "target_type": "app_role",
+ "target_id": role["id"],
+ "enabled": True,
+ }
+ preview = tenant_request(
+ token,
+ f"/apps/{app_id}/rbac/mappings/preview",
+ method="POST",
+ data=mapping_input,
+ )
+ if preview.get("data", {}).get("affected_user_count", 0) < 1:
+ raise AssertionError(f"{service['name']}: preview did not include the normal user: {preview}")
+
+ created = tenant_request(
+ token,
+ f"/apps/{app_id}/rbac/mappings",
+ method="POST",
+ data=mapping_input,
+ expected=201,
+ )["data"]
+ state = {**service, "role_id": role["id"], "mapping_id": created["id"], "was_explicit": was_explicit, "explicit_removed": False, "mapping_deleted": False}
+
+ if was_explicit:
+ tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles/{role['id']}", method="DELETE")
+ state["explicit_removed"] = True
+
+ direct = tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/roles")
+ if any(item.get("id") == role["id"] for item in direct.get("roles", [])):
+ raise AssertionError(f"{service['name']}: mapped role was materialized as an explicit assignment")
+ mapped = effective_role(direct, role_code) or effective_role(
+ tenant_request(token, f"/apps/{app_id}/users/{USER_ID}/effective-grants").get("data", {}),
+ role_code,
+ )
+ if not mapped or not any(
+ source.get("type") == "tenant_mapping" and source.get("mapping_id") == created["id"]
+ for source in mapped.get("sources", [])
+ ):
+ raise AssertionError(f"{service['name']}: effective role has no mapping provenance: {mapped}")
+
+ # A referenced target must not be deletable, including while the mapping is active.
+ tenant_request(token, f"/apps/{app_id}/roles/{role['id']}", method="DELETE", expected=409)
+ return state
+
+
+def prove_revocation(token, user_bp_token, state):
+ # Keep the same application session across the policy change. The demo apps
+ # ask BasaltPass over S2S on every protected request, so this verifies live
+ # revocation rather than merely proving that a newly issued token is denied.
+ active_app_token = oauth_login(state["backend"], user_bp_token)
+ json_request(f"{state['backend']}{state['protected']}", token=active_app_token)
+ tenant_request(
+ token,
+ f"/apps/{state['app_id']}/rbac/mappings/{state['mapping_id']}",
+ method="DELETE",
+ expected=204,
+ )
+ state["mapping_deleted"] = True
+ grants = tenant_request(
+ token,
+ f"/apps/{state['app_id']}/users/{USER_ID}/effective-grants",
+ )["data"]
+ if effective_role(grants, state["role"]):
+ raise AssertionError(f"{state['name']}: deleted mapping still grants {state['role']}")
+
+ json_request(
+ f"{state['backend']}{state['protected']}",
+ token=active_app_token,
+ expected=403,
+ )
+
+
+def restore(token, states):
+ errors = []
+ for state in reversed(states):
+ try:
+ if state.get("was_explicit") and state.get("explicit_removed"):
+ tenant_request(
+ token,
+ f"/apps/{state['app_id']}/users/{USER_ID}/roles",
+ method="POST",
+ data={"role_ids": [state["role_id"]]},
+ )
+ state["explicit_removed"] = False
+ except Exception as exc: # cleanup must continue for the remaining apps
+ errors.append(f"restore {state['name']} explicit role: {exc}")
+ try:
+ if state.get("mapping_id") and not state.get("mapping_deleted"):
+ tenant_request(
+ token,
+ f"/apps/{state['app_id']}/rbac/mappings/{state['mapping_id']}",
+ method="DELETE",
+ expected=204,
+ )
+ state["mapping_deleted"] = True
+ except Exception as exc:
+ errors.append(f"delete {state['name']} mapping: {exc}")
+ if errors:
+ raise AssertionError("; ".join(errors))
+
+
+def main():
+ admin_bp_token = bp_login(required("BP_ADMIN_EMAIL"), required("BP_ADMIN_PASSWORD"))
+ user_bp_token = bp_login(required("BP_USER_EMAIL"), required("BP_USER_PASSWORD"))
+ json_request(
+ f"{BP_BASE}/api/v1/auth/console/authorize",
+ method="POST",
+ data={"target": "tenant", "tenant_id": TENANT_ID},
+ token=user_bp_token,
+ headers={"X-Auth-Scope": "user"},
+ expected=403,
+ )
+ tenant_token = tenant_console_token(admin_bp_token)
+ states = []
+ results = {}
+ test_error = None
+ try:
+ for service in SERVICES:
+ states.append(prepare_mapping(tenant_token, service))
+
+ # These calls enter through each application's OAuth callback and use its
+ # own S2S credentials to fetch the dynamically resolved role from BP.
+ for service in SERVICES:
+ results[service["name"]] = check_service(
+ service["name"], service["backend"], admin_bp_token, user_bp_token
+ )
+
+ for state in states:
+ prove_revocation(tenant_token, user_bp_token, state)
+ results[state["name"]]["mapping_revoked_immediately"] = True
+ results[state["name"]]["no_inherited_assignment_row"] = True
+ except Exception as exc:
+ test_error = exc
+ finally:
+ try:
+ restore(tenant_token, states)
+ except Exception as cleanup_exc:
+ if test_error:
+ raise AssertionError(f"test failed: {test_error}; cleanup failed: {cleanup_exc}") from cleanup_exc
+ raise
+ if test_error:
+ raise test_error
+ print(json.dumps(results, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+ main()