feat(auth): LDAP and SSO users keep their source; directory TLS is real (#5036)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 44s
CI / unit (push) Failing after 1m5s
CI / publish (push) Skipped

#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:
2026-10-06 11:10:40 -04:00
co-authored by Claude Opus 5.5
parent b1da073677
commit f944cdb9b0
11 changed files with 405 additions and 41 deletions
+12 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import enum
import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, Enum, String
from sqlalchemy import Boolean, DateTime, Enum, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -13,8 +13,16 @@ class UserRole(str, enum.Enum):
viewer = "viewer"
# Where a user's identity lives. A local user signs in with a Steward
# password; an ldap or oidc user is created at their first sign-in, holds an
# unusable password, and is found again only by (auth_source, external_id) —
# never by a matching username, so a directory cannot sign in as a local account.
AUTH_SOURCES = ("local", "ldap", "oidc")
class User(Base):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("auth_source", "external_id", name="uq_users_auth_source_external_id"),)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
@@ -25,3 +33,6 @@ class User(Base):
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
auth_source: Mapped[str] = mapped_column(String(16), nullable=False, default="local", server_default="local")
# The provider's stable id: the LDAP entry DN, or the OIDC `sub`. None for local users.
external_id: Mapped[str | None] = mapped_column(String(512), nullable=True)