You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
External IdP tokens carry group memberships (e.g., Entra group object ID GUIDs) that never match ContextForge team IDs. This story adds the translation layer so Layer-1 token scoping keeps working unchanged in trust mode:
Idempotent Alembic migration + ORM model for external_group_mappings: issuer, tenant (nullable), external_group_id, cf_team_id (FK→teams), cf_role (nullable String, validated against the roles table at write time — NOT a foreign key; the roles.name partial unique index forbids one. See Group-to-role mapping below), validation_status / last_validated_at; unique on (issuer, tenant, external_group_id).
Entra group membership must drive both Layer-1 (team visibility) and Layer-2 (invocation permission) authorisation decisions without requiring the Entra token to carry ContextForge-native role names. The cf_role column on external_group_mappings enables this:
When cf_role is NULL the row contributes only to token_teams (unchanged behaviour).
This is the mechanism by which a deployment (e.g., BASF) can map a single Entra group GUID to both a CF team and the developer (or viewer) role that grants a2a.invoke, without any change to the Entra token's claims.
For per-agent invocation isolation, agents MUST be registered with visibility = "team" and assigned to a dedicated CF team. visibility = "public" agents are accessible to all authenticated callers regardless of token_teams — the Layer-1 gate does not apply. Deployers who require per-agent isolation (e.g., Agent A accessible only to AgentA-Team members) must set visibility = "team". This constraint must be documented in the trust-mode deployment guide (story #5906 docs sweep).
Must NOT
No redesign of the two-layer model — mapping feeds Layer-1 token_teams; Layer-2 RBAC untouched.
AC-visibility-gate: Two integration tests covering agent visibility semantics:
Team-visibility isolation: Agent-A (visibility=team, team=CF-Team-A) and Agent-B (visibility=team, team=CF-Team-B). Caller token maps only to CF-Team-A. POST /a2a/agent-a/invoke → 200; POST /a2a/agent-b/invoke → 404 (404-not-403 convention per existing _check_agent_access behaviour to avoid leaking agent existence).
Public-visibility baseline: Both agents set visibility=public. Both invocations → 200. Documents the deliberate behaviour that public agents are accessible platform-wide regardless of team membership.
Epic: #5885 | Added: 2026-07-29 per ultimate-user feedback
Depends on: #5895 (Epic 1 gate). Blocks: #5900, #6272.
What
External IdP tokens carry group memberships (e.g., Entra group object ID GUIDs) that never match ContextForge team IDs. This story adds the translation layer so Layer-1 token scoping keeps working unchanged in trust mode:
external_group_mappings:issuer,tenant(nullable),external_group_id,cf_team_id(FK→teams),cf_role(nullable String, validated against therolestable at write time — NOT a foreign key; theroles.namepartial unique index forbids one. See Group-to-role mapping below),validation_status/last_validated_at; unique on(issuer, tenant, external_group_id).resolve_external_groups_to_teams(issuer, tenant, groups, db)invoked by the trust branch after claims extraction, beforetoken_teamsis computed. Unmapped groups contribute nothing — fail-closed. Whencf_roleis populated the resolver additionally returns the associated role names so that Layer-2 RBAC can be satisfied without a nativerolesclaim in the JWT (consumed by Claims extraction module + trust-mode virtual principal contract #5899 / Trust-mode branch in get_current_user (skip user lookup, keep jti revocation) #5900).validation_status="unknown".Group-to-role mapping (BASF requirement)
Entra group membership must drive both Layer-1 (team visibility) and Layer-2 (invocation permission) authorisation decisions without requiring the Entra token to carry ContextForge-native role names. The
cf_rolecolumn onexternal_group_mappingsenables this:cf_roleis NULL the row contributes only totoken_teams(unchanged behaviour).cf_roleis non-NULL the resolver annotates the virtual principal with that role, which the trust-mode path (Claims extraction module + trust-mode virtual principal contract #5899, Trust-mode branch in get_current_user (skip user lookup, keep jti revocation) #5900) merges with any roles claim present in the token. The combined role set is then resolved against the server-siderolestable per the existing resolution rule.developer(orviewer) role that grantsa2a.invoke, without any change to the Entra token's claims.Deployment contract
For per-agent invocation isolation, agents MUST be registered with
visibility = "team"and assigned to a dedicated CF team.visibility = "public"agents are accessible to all authenticated callers regardless oftoken_teams— the Layer-1 gate does not apply. Deployers who require per-agent isolation (e.g., Agent A accessible only to AgentA-Team members) must setvisibility = "team". This constraint must be documented in the trust-mode deployment guide (story #5906 docs sweep).Must NOT
token_teams; Layer-2 RBAC untouched.token_teams.Acceptance criteria
cf_rolenullable column.uv run pytest tests -k "group_mapping or external_group" -qgreen, including fail-closed and CRUD deny-path cases (unauthenticated, wrong team, insufficient permissions).token_teams= mapped CF team IDs (integration test through the funnel branch; strict-xfail until Trust-mode branch in get_current_user (skip user lookup, keep jti revocation) #5900 lands).visibility=team, team=CF-Team-A) and Agent-B (visibility=team, team=CF-Team-B). Caller token maps only to CF-Team-A.POST /a2a/agent-a/invoke→ 200;POST /a2a/agent-b/invoke→ 404 (404-not-403 convention per existing_check_agent_accessbehaviour to avoid leaking agent existence).visibility=public. Both invocations → 200. Documents the deliberate behaviour that public agents are accessible platform-wide regardless of team membership.external_group_mappingsrow: Entra-Group-GUID-1 → CF-Team-A;cf_role=developer.groups=[Entra-Group-GUID-1].POST /a2a/agent-a/invoke→ 200;POST /a2a/agent-b/invoke→ 404.external_group_mappingsrow →token_teams = []→ anyvisibility=teamagent → 404;visibility=publicagents → 200.