feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
#5036 "SSO and LDAP: users keep their auth source, no merging into local accounts, is_active checked, LDAP TLS real and filter escaped". - users gain auth_source (local/ldap/oidc) and external_id (migration 0027). A provider account is found by (source, subject) only: the LDAP entry DN or the OIDC sub. A first sign-in whose username or email another account holds is refused with a message naming why; a disabled user stays out. - Local passwords sign in local accounts only. - LDAP: port 636 is LDAPS, otherwise StartTLS before any bind; the server certificate is checked against the system CAs or a CA pasted in Settings -> Auth. The login name is escaped into the filter (RFC 4515), and an empty password is refused before the directory sees it. - OIDC: the ID token must carry this sign-in's nonce and the userinfo sub. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
"""Integration: provider accounts are found by (source, subject) against real
|
||||
Postgres, never merged into a local account by a matching name."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
def test_provider_accounts_keep_their_source(app):
|
||||
from sqlalchemy import select, update
|
||||
from steward.auth.routes import _provision_external_user
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
local = f"loc-{tag}"
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(username=local, email=f"{local}@test", password_hash="x",
|
||||
role=UserRole.admin))
|
||||
|
||||
# A directory entry with a local user's name is refused, not merged.
|
||||
user, refusal = await _provision_external_user(
|
||||
app, "ldap", f"uid={local},dc=x", local, "", "admin")
|
||||
assert user is None and f"named {local}" in refusal and "LDAP" in refusal
|
||||
|
||||
# First sign-in creates the account; the next finds it by subject and
|
||||
# follows the provider's role.
|
||||
name = f"sso-{tag}"
|
||||
first, refusal = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "viewer")
|
||||
assert refusal is None
|
||||
assert (first.auth_source, first.external_id, first.role) == ("oidc", f"sub-{tag}", UserRole.viewer)
|
||||
again, _ = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "operator")
|
||||
assert again.id == first.id and again.role == UserRole.operator
|
||||
|
||||
# Another subject claiming the same username is someone else.
|
||||
other, refusal = await _provision_external_user(app, "oidc", f"sub2-{tag}", name, "", "admin")
|
||||
assert other is None and refusal
|
||||
|
||||
# Disabled in Steward stays out, whatever the provider says.
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
await s.execute(update(User).where(User.id == first.id).values(is_active=False))
|
||||
gone, refusal = await _provision_external_user(app, "oidc", f"sub-{tag}", name, "", "admin")
|
||||
assert gone is None and "disabled" in refusal
|
||||
|
||||
async with app.db_sessionmaker() as s:
|
||||
row = (await s.execute(select(User).where(User.username == local))).scalar_one()
|
||||
assert (row.auth_source, row.external_id, row.role) == ("local", None, UserRole.admin)
|
||||
|
||||
asyncio.run(_go())
|
||||
Reference in New Issue
Block a user