mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
424 lines
17 KiB
Python
424 lines
17 KiB
Python
"""Integration tests for OIDC group sync (#3107).
|
|
|
|
Same regression contract as the LDAP group sync (test_ldap_group_sync.py,
|
|
#1292): the sync manages only the Bambuddy groups named in the provider's
|
|
group_mapping values, and every login replaces exactly that slice. Manual
|
|
assignments to groups outside the mapping survive; revocation at the IdP
|
|
propagates on the next login.
|
|
|
|
One deliberate difference from LDAP: there is no default-group fallback in
|
|
the OIDC sync. The provider's default_group_id is applied once at account
|
|
creation (routes/mfa.py) and never re-asserted, so promoting an auto-created
|
|
user out of Viewers is a manual action that sticks.
|
|
"""
|
|
|
|
import logging
|
|
from typing import NoReturn
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.orm import selectinload
|
|
|
|
from backend.app.models.group import Group
|
|
from backend.app.models.oidc_provider import OIDCProvider
|
|
from backend.app.models.user import User
|
|
from backend.app.schemas.auth import OIDCProviderCreate
|
|
from backend.app.services.oidc_group_sync import (
|
|
_MAX_CLAIM_ITEMS,
|
|
extract_idp_groups,
|
|
resolve_oidc_group_mapping,
|
|
sync_oidc_user_groups,
|
|
)
|
|
|
|
|
|
async def _make_group(db: AsyncSession, name: str) -> Group:
|
|
group = Group(name=name, description=f"Test group {name}")
|
|
db.add(group)
|
|
await db.commit()
|
|
await db.refresh(group)
|
|
return group
|
|
|
|
|
|
async def _make_user(db: AsyncSession, username: str, groups: list[Group]) -> User:
|
|
user = User(
|
|
username=username,
|
|
email=f"{username}@example.com",
|
|
password_hash=None,
|
|
role="user",
|
|
auth_source="oidc",
|
|
is_active=True,
|
|
)
|
|
user.groups = groups
|
|
db.add(user)
|
|
await db.commit()
|
|
await db.refresh(user, attribute_names=["groups"])
|
|
return user
|
|
|
|
|
|
# ─── claim-shape helpers ──────────────────────────────────────────────────────
|
|
|
|
|
|
class TestExtractIdpGroups:
|
|
"""Providers disagree on the groups-claim shape (#3107). All accepted."""
|
|
|
|
def test_json_array(self):
|
|
assert extract_idp_groups(["fablab-staff", "students"]) == ["fablab-staff", "students"]
|
|
|
|
def test_space_separated_string(self):
|
|
assert extract_idp_groups("fablab-staff students") == ["fablab-staff", "students"]
|
|
|
|
def test_comma_separated_string(self):
|
|
assert extract_idp_groups("fablab-staff, students") == ["fablab-staff", "students"]
|
|
|
|
def test_single_group_string(self):
|
|
assert extract_idp_groups("fablab-staff") == ["fablab-staff"]
|
|
|
|
def test_duplicates_removed(self):
|
|
assert extract_idp_groups(["a", "b", "a", "b "]) == ["a", "b"]
|
|
|
|
def test_none_and_non_group_payloads(self):
|
|
assert extract_idp_groups(None) == []
|
|
assert extract_idp_groups(42) == []
|
|
assert extract_idp_groups({"odd": "shape"}) == []
|
|
assert extract_idp_groups(["ok", 7, None, ""]) == ["ok"]
|
|
|
|
def test_list_claim_is_bounded(self):
|
|
# Review on #3122: the bound is the only defence against a hostile
|
|
# oversized token, and it must hold for every accepted shape, not
|
|
# just the list one.
|
|
oversized = [f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100)]
|
|
result = extract_idp_groups(oversized)
|
|
assert len(result) == _MAX_CLAIM_ITEMS
|
|
assert result[0] == "g0"
|
|
assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
|
|
|
|
def test_space_separated_claim_is_bounded(self):
|
|
# The shape the original bound missed: a string claim splits into
|
|
# arbitrarily many fragments, so the slice has to apply after
|
|
# splitting, not only on the list path.
|
|
oversized = " ".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
|
|
result = extract_idp_groups(oversized)
|
|
assert len(result) == _MAX_CLAIM_ITEMS
|
|
assert result[-1] == f"g{_MAX_CLAIM_ITEMS - 1}"
|
|
|
|
def test_comma_separated_claim_is_bounded(self):
|
|
# No spaces around the commas: the split happens on raw fragments,
|
|
# so ", "-joined input would spend half the budget on empty
|
|
# fragments. The contract being pinned is the upper bound.
|
|
oversized = ",".join(f"g{i}" for i in range(_MAX_CLAIM_ITEMS + 100))
|
|
result = extract_idp_groups(oversized)
|
|
assert len(result) == _MAX_CLAIM_ITEMS
|
|
|
|
|
|
class TestResolveMapping:
|
|
def test_case_insensitive_on_idp_side(self):
|
|
assert resolve_oidc_group_mapping(["IDP-STAFF"], {"idp-staff": "Operators"}) == ["Operators"]
|
|
|
|
def test_unmapped_groups_ignored(self):
|
|
assert resolve_oidc_group_mapping(["nope", "idp-staff"], {"idp-staff": "Operators"}) == ["Operators"]
|
|
|
|
def test_empty_mapping_disables(self):
|
|
assert resolve_oidc_group_mapping(["idp-staff"], {}) == []
|
|
|
|
def test_two_idp_groups_to_one_bambuddy_group(self):
|
|
mapping = {"staff": "Operators", "admins": "Operators"}
|
|
assert resolve_oidc_group_mapping(["admins", "staff"], mapping) == ["Operators"]
|
|
|
|
|
|
# ─── sync semantics ───────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestSyncOidcUserGroups:
|
|
@pytest.mark.asyncio
|
|
async def test_adds_mapped_group_on_login(self, db_session: AsyncSession):
|
|
operators = await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "alice", [])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": ["idp-staff"]},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.id for g in user.groups} == {operators.id}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_group_survives_login(self, db_session: AsyncSession):
|
|
"""The #1292 contract: a group outside the mapping is a manual
|
|
assignment and must never be touched by the sync."""
|
|
admins = await _make_group(db_session, "Administrators")
|
|
await _make_group(db_session, "Operators")
|
|
|
|
user = await _make_user(db_session, "alice", [admins])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": ["idp-staff"]},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == {"Administrators", "Operators"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revocation_at_idp_propagates(self, db_session: AsyncSession):
|
|
"""Losing the IdP group must remove the mapped Bambuddy group on the
|
|
next login — otherwise IdP-side revocation would be decorative."""
|
|
operators = await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "bob", [operators])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": []},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_revocation_keeps_manual_groups(self, db_session: AsyncSession):
|
|
admins = await _make_group(db_session, "Administrators")
|
|
operators = await _make_group(db_session, "Operators")
|
|
|
|
user = await _make_user(db_session, "carol", [admins, operators])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": []},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == {"Administrators"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_assignment_to_managed_group_overridden(self, db_session: AsyncSession):
|
|
"""An admin who manually grants a mapped group is overridden by IdP
|
|
truth, same as LDAP: revocation must work for those users too."""
|
|
operators = await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "dave", [operators])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": []},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_mapping_is_a_noop(self, db_session: AsyncSession):
|
|
"""Default state for every upgraded install: nothing configured, so
|
|
nothing changes — including groups that would have matched a mapping
|
|
if one existed."""
|
|
admins = await _make_group(db_session, "Administrators")
|
|
user = await _make_user(db_session, "eve", [admins])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={},
|
|
claims={"groups": ["idp-staff"]},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == {"Administrators"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_claim_is_not_fatal(self, db_session: AsyncSession):
|
|
"""A provider that never sends the claim means 'no mapped groups',
|
|
not an error: the login must proceed and the managed slice clears."""
|
|
operators = await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "frank", [operators])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={}, # claim absent entirely
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == set()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_mapping_to_deleted_group_skipped(self, db_session: AsyncSession):
|
|
"""A dangling mapping value (group deleted after the mapping was saved)
|
|
is skipped at sync time, mirroring default_group_id's SQLite story."""
|
|
await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "grace", [])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators", "idp-ghost": "DeletedGroup"},
|
|
claims={"groups": ["idp-staff", "idp-ghost"]},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.name for g in user.groups} == {"Operators"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_claim_name(self, db_session: AsyncSession):
|
|
"""group_claim='roles' reads the roles claim and ignores a groups
|
|
claim that happens to be present."""
|
|
operators = await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "heidi", [])
|
|
|
|
await sync_oidc_user_groups(
|
|
db_session,
|
|
user,
|
|
group_claim="roles",
|
|
group_mapping={"op": "Operators"},
|
|
claims={"roles": ["op"], "groups": ["unrelated"]},
|
|
)
|
|
await db_session.refresh(user, attribute_names=["groups"])
|
|
assert {g.id for g in user.groups} == {operators.id}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_failure_never_blocks_login(self, db_session: AsyncSession, monkeypatch, caplog):
|
|
"""The service's contract with oidc_callback: never raise. A failure
|
|
mid-sync is logged and the user keeps the groups they had — the login
|
|
already authenticated, so the sync must not take it down with it.
|
|
|
|
The commit is the interesting failure point: by then the user object
|
|
is dirty, so the except path's rollback has real work to do and the
|
|
in-memory relationship is post-rollback state. Database truth is
|
|
re-selected rather than read off the expired instance."""
|
|
admins = await _make_group(db_session, "Administrators")
|
|
await _make_group(db_session, "Operators")
|
|
user = await _make_user(db_session, "ivan", [admins])
|
|
user_id = user.id # captured pre-sync: the rollback expires the whole instance, PK included
|
|
|
|
async def _failing_commit() -> NoReturn:
|
|
raise RuntimeError("simulated commit failure")
|
|
|
|
monkeypatch.setattr(db_session, "commit", _failing_commit)
|
|
|
|
with caplog.at_level(logging.ERROR):
|
|
await sync_oidc_user_groups( # must not raise
|
|
db_session,
|
|
user,
|
|
group_claim="groups",
|
|
group_mapping={"idp-staff": "Operators"},
|
|
claims={"groups": ["idp-staff"]},
|
|
)
|
|
|
|
fresh = (
|
|
await db_session.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
|
|
).scalar_one()
|
|
assert {g.name for g in fresh.groups} == {"Administrators"}
|
|
assert "OIDC group sync failed for user ivan" in caplog.text
|
|
|
|
|
|
# ─── schema validation ────────────────────────────────────────────────────────
|
|
|
|
|
|
class TestProviderSchema:
|
|
def test_create_defaults(self):
|
|
provider = OIDCProviderCreate(name="t", issuer_url="https://id.example.com", client_id="a", client_secret="b")
|
|
assert provider.group_claim == "groups"
|
|
assert provider.group_mapping == {}
|
|
|
|
def test_create_with_mapping(self):
|
|
provider = OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_claim="roles",
|
|
group_mapping={"op": "Operators"},
|
|
)
|
|
assert provider.group_claim == "roles"
|
|
assert provider.group_mapping == {"op": "Operators"}
|
|
|
|
def test_invalid_group_claim_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_claim="not a claim!",
|
|
)
|
|
|
|
def test_non_object_mapping_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_mapping=["not", "an", "object"],
|
|
)
|
|
|
|
def test_empty_mapping_values_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_mapping={"op": " "},
|
|
)
|
|
|
|
def test_case_colliding_mapping_keys_rejected(self):
|
|
"""Review round 2: {"Admins": ..., "admins": ...} would silently
|
|
collapse in the sync's case-insensitive lookup — rejected at save."""
|
|
with pytest.raises(ValidationError):
|
|
OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_mapping={"Admins": "Administrators", "admins": "Viewers"},
|
|
)
|
|
|
|
def test_case_colliding_keys_same_target_also_rejected(self):
|
|
with pytest.raises(ValidationError):
|
|
OIDCProviderCreate(
|
|
name="t",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_mapping={"Admins": "Operators", "admins": "Operators"},
|
|
)
|
|
|
|
def test_update_none_leaves_unchanged(self):
|
|
from backend.app.schemas.auth import OIDCProviderUpdate
|
|
|
|
update = OIDCProviderUpdate()
|
|
assert update.group_claim is None
|
|
assert update.group_mapping is None
|
|
|
|
|
|
# ─── model column round-trip ──────────────────────────────────────────────────
|
|
|
|
|
|
class TestProviderModelRoundTrip:
|
|
@pytest.mark.asyncio
|
|
async def test_columns_persist(self, db_session: AsyncSession):
|
|
provider = OIDCProvider(
|
|
name="idp-test",
|
|
issuer_url="https://id.example.com",
|
|
client_id="a",
|
|
client_secret="b",
|
|
group_claim="roles",
|
|
group_mapping={"op": "Operators"},
|
|
)
|
|
db_session.add(provider)
|
|
await db_session.commit()
|
|
await db_session.refresh(provider)
|
|
assert provider.group_claim == "roles"
|
|
assert provider.group_mapping == {"op": "Operators"}
|