auth.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732
  1. import re
  2. from typing import Literal
  3. from pydantic import BaseModel, Field, field_validator, model_validator
  4. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  5. def _validate_password_complexity(v: str) -> str:
  6. """Enforce minimum password complexity (M-C).
  7. Requires at least one uppercase letter, one lowercase letter, one digit,
  8. and one special character in addition to the min_length=8 Field constraint.
  9. """
  10. if not re.search(r"[A-Z]", v):
  11. raise ValueError("Password must contain at least one uppercase letter")
  12. if not re.search(r"[a-z]", v):
  13. raise ValueError("Password must contain at least one lowercase letter")
  14. if not re.search(r"\d", v):
  15. raise ValueError("Password must contain at least one digit")
  16. if not re.search(r"[^A-Za-z0-9]", v):
  17. raise ValueError("Password must contain at least one special character")
  18. return v
  19. class GroupBrief(BaseModel):
  20. """Brief group info for embedding in user responses."""
  21. id: int
  22. name: str
  23. class Config:
  24. from_attributes = True
  25. class LoginRequest(BaseModel):
  26. username: str = Field(..., max_length=150)
  27. password: str = Field(..., max_length=256)
  28. class LoginResponse(BaseModel):
  29. access_token: str | None = None
  30. token_type: str = "bearer"
  31. user: "UserResponse | None" = None
  32. # Set when 2FA is required; the frontend must call /auth/2fa/verify
  33. requires_2fa: bool = False
  34. pre_auth_token: str | None = None
  35. two_fa_methods: list[str] = []
  36. class UserCreate(BaseModel):
  37. username: str = Field(..., max_length=150)
  38. password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
  39. email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
  40. role: str = "user"
  41. group_ids: list[int] | None = None
  42. @field_validator("password")
  43. @classmethod
  44. def validate_password(cls, v: str | None) -> str | None:
  45. if v is not None:
  46. _validate_password_complexity(v)
  47. return v
  48. class UserUpdate(IgnoresNullForRequiredColumns):
  49. __orm_table__ = "users"
  50. username: str | None = Field(default=None, max_length=150)
  51. password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
  52. email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
  53. role: str | None = None
  54. is_active: bool | None = None
  55. group_ids: list[int] | None = None
  56. @field_validator("password")
  57. @classmethod
  58. def validate_password(cls, v: str | None) -> str | None:
  59. if v is not None:
  60. _validate_password_complexity(v)
  61. return v
  62. class UserResponse(BaseModel):
  63. id: int
  64. username: str
  65. email: str | None = None
  66. role: str # Deprecated, kept for backward compatibility
  67. is_active: bool
  68. is_admin: bool # Computed from role and group membership
  69. auth_source: str = "local" # "local" or "ldap"
  70. groups: list[GroupBrief] = []
  71. permissions: list[str] = [] # All permissions from groups
  72. created_at: str
  73. class Config:
  74. from_attributes = True
  75. class UserSlim(BaseModel):
  76. """Just enough to resolve a user id to a display name (#1894).
  77. Deliberately narrower than ``UserResponse``: no email, role, auth source,
  78. group membership or permission set. Adding a field here widens what every
  79. ``can_read_status`` API key can read about every account, so treat this
  80. shape as the contract rather than a starting point.
  81. """
  82. id: int
  83. username: str
  84. class Config:
  85. from_attributes = True
  86. class LDAPSearchResultResponse(BaseModel):
  87. """One match from GET /auth/ldap/search — surfaced in the admin UI."""
  88. username: str
  89. email: str | None = None
  90. display_name: str | None = None
  91. dn: str
  92. already_provisioned: bool = False # True if this username already exists as a BamBuddy user
  93. class LDAPTestRequest(BaseModel):
  94. """The LDAP settings in the form, tested before they are saved.
  95. A field left out uses the saved value; an empty bind password keeps the
  96. saved one.
  97. """
  98. ldap_server_url: str | None = None
  99. ldap_bind_dn: str | None = None
  100. ldap_bind_password: str | None = None
  101. ldap_search_base: str | None = None
  102. ldap_user_filter: str | None = None
  103. ldap_security: str | None = None
  104. class LDAPProvisionRequest(BaseModel):
  105. """Body for POST /auth/ldap/provision. Username is re-resolved via the
  106. service-account bind, so the request only carries the directory username
  107. the admin picked from the search results."""
  108. username: str = Field(..., max_length=150)
  109. class ChangePasswordRequest(BaseModel):
  110. current_password: str = Field(..., max_length=256) # M-NEW-3: cap before pbkdf2
  111. new_password: str = Field(..., min_length=8, max_length=256)
  112. @field_validator("new_password")
  113. @classmethod
  114. def validate_new_password(cls, v: str) -> str:
  115. return _validate_password_complexity(v)
  116. class SetupRequest(BaseModel):
  117. auth_enabled: bool
  118. admin_username: str | None = Field(default=None, max_length=150)
  119. admin_password: str | None = Field(default=None, max_length=256)
  120. # Password complexity is NOT validated at the schema layer. When re-enabling auth
  121. # with an existing admin user (or when LDAP is the auth backend), the frontend
  122. # still sends whatever is in the password field but the route ignores it.
  123. # Enforcing complexity here would reject those legitimate flows. The route body
  124. # applies the check only when a brand-new local admin is actually being created.
  125. class SetupResponse(BaseModel):
  126. auth_enabled: bool
  127. admin_created: bool | None = None
  128. class ForgotPasswordRequest(BaseModel):
  129. email: str = Field(..., max_length=254) # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup
  130. class ForgotPasswordConfirmRequest(BaseModel):
  131. token: str = Field(..., max_length=128)
  132. new_password: str = Field(..., min_length=8, max_length=256)
  133. @field_validator("new_password")
  134. @classmethod
  135. def validate_new_password(cls, v: str) -> str:
  136. return _validate_password_complexity(v)
  137. class ForgotPasswordResponse(BaseModel):
  138. message: str
  139. class ResetPasswordRequest(BaseModel):
  140. user_id: int
  141. class ResetPasswordResponse(BaseModel):
  142. message: str
  143. class SMTPSettings(BaseModel):
  144. smtp_host: str
  145. smtp_port: int
  146. smtp_username: str | None = None # Optional when auth is disabled
  147. smtp_password: str | None = None # Optional for read operations or when auth is disabled
  148. smtp_security: str = "starttls" # 'starttls', 'ssl', 'none'
  149. smtp_auth_enabled: bool = True
  150. smtp_from_email: str
  151. smtp_from_name: str = "BamBuddy"
  152. # Deprecated field for backward compatibility
  153. smtp_use_tls: bool | None = None
  154. class TestSMTPRequest(BaseModel):
  155. test_recipient: str
  156. # The settings in the form, tested before they are saved; left out, the
  157. # saved ones. An empty password keeps the saved one.
  158. smtp: SMTPSettings | None = None
  159. class TestSMTPResponse(BaseModel):
  160. success: bool
  161. message: str
  162. # ---------------------------------------------------------------------------
  163. # 2FA / MFA schemas
  164. # ---------------------------------------------------------------------------
  165. class TwoFAStatusResponse(BaseModel):
  166. totp_enabled: bool
  167. email_otp_enabled: bool
  168. backup_codes_remaining: int
  169. class TOTPSetupResponse(BaseModel):
  170. """Returned when a user initiates TOTP setup. The frontend should display
  171. the QR code image (base64 PNG) and ask the user to scan it, then call
  172. /auth/2fa/totp/enable with a valid code to confirm."""
  173. secret: str # base32 secret (shown as fallback text)
  174. qr_code_b64: str # base64-encoded PNG of the QR code
  175. issuer: str
  176. class TOTPSetupRequest(BaseModel):
  177. """Optional body for POST /auth/2fa/totp/setup.
  178. Only required when re-initialising setup while an active TOTP record exists.
  179. Provide the current TOTP code (from the existing authenticator app) to
  180. confirm intent — mirrors the verification requirement in disable_totp.
  181. """
  182. code: str | None = Field(default=None, max_length=8) # L-NEW-2: bound before pyotp
  183. class TOTPEnableRequest(BaseModel):
  184. code: str # 6-digit TOTP code from the authenticator app
  185. @field_validator("code")
  186. @classmethod
  187. def validate_code(cls, v: str) -> str:
  188. v = v.strip()
  189. if not v.isdigit() or len(v) != 6:
  190. raise ValueError("TOTP code must be exactly 6 digits")
  191. return v
  192. class TOTPEnableResponse(BaseModel):
  193. message: str
  194. backup_codes: list[str] # plain-text codes shown once; user must save them
  195. class TOTPDisableRequest(BaseModel):
  196. """Requires a valid TOTP code OR a backup code to disable TOTP."""
  197. code: str = Field(..., max_length=128)
  198. class BackupCodesResponse(BaseModel):
  199. backup_codes: list[str]
  200. message: str
  201. class EmailOTPEnableRequest(BaseModel):
  202. """No body required — email is taken from the authenticated user's profile."""
  203. pass
  204. class TwoFAVerifyRequest(BaseModel):
  205. pre_auth_token: str = Field(..., max_length=128)
  206. # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.
  207. # max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.
  208. code: str = Field(..., min_length=6, max_length=8)
  209. method: Literal["totp", "email", "backup"] = "totp"
  210. @field_validator("code")
  211. @classmethod
  212. def validate_code_format(cls, v: str) -> str:
  213. v = v.strip()
  214. if not re.match(r"^[A-Za-z0-9]{6,8}$", v):
  215. raise ValueError("Code must be 6–8 alphanumeric characters")
  216. return v.upper() # normalise backup codes to uppercase
  217. class TwoFAVerifyResponse(BaseModel):
  218. access_token: str
  219. token_type: str = "bearer"
  220. user: "UserResponse"
  221. class EmailOTPSendRequest(BaseModel):
  222. pre_auth_token: str = Field(..., max_length=128)
  223. class EmailOTPEnableConfirmRequest(BaseModel):
  224. """Body for the second step of email OTP enable: verify the proof-of-possession code."""
  225. setup_token: str = Field(..., max_length=128)
  226. # L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.
  227. code: str = Field(..., min_length=6, max_length=6)
  228. @field_validator("code")
  229. @classmethod
  230. def validate_code_digits(cls, v: str) -> str:
  231. v = v.strip()
  232. if not v.isdigit() or len(v) != 6:
  233. raise ValueError("Email OTP setup code must be exactly 6 digits")
  234. return v
  235. class EmailOTPDisableRequest(BaseModel):
  236. """Requires the account password to disable email OTP."""
  237. password: str = Field(..., max_length=256)
  238. class AdminDisable2FARequest(BaseModel):
  239. """Admin must supply their own password as re-auth before disabling 2FA for another user.
  240. OIDC/LDAP-only admins (no local password_hash) are exempt from this check.
  241. """
  242. admin_password: str | None = Field(default=None, max_length=256)
  243. # ---------------------------------------------------------------------------
  244. # OIDC schemas
  245. # ---------------------------------------------------------------------------
  246. AUTO_LINK_REQUIREMENTS_ERROR = (
  247. "auto_link_existing_accounts requires require_email_verified=True when email_claim='email'"
  248. )
  249. def _validate_email_claim_name(v: str) -> str:
  250. # Accepts only alphanumeric/underscore/hyphen claim names starting with a letter —
  251. # prevents log injection and limits the attack surface of operator-supplied claim names.
  252. if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-]{0,63}", v):
  253. raise ValueError("Invalid claim name")
  254. return v
  255. def _validate_group_claim_name(v: str) -> str:
  256. """#3107 — like _validate_email_claim_name, but also allows one slash.
  257. Auth0 (and Auth0-compatible providers) only expose custom claims under a
  258. non-reserved namespace, e.g. ``https://example.com/roles`` or ``app/roles``,
  259. so the email-claim charset would refuse every valid Auth0 group claim.
  260. The slash is structurally safe here: the value never reaches a URL, a
  261. path or SQL — it is only a JWT claim lookup key inside ``claims.get`` —
  262. so the wider charset does not widen any injection surface. The 64-char
  263. cap and the "starts with a letter" rule are kept. The full-URL form of
  264. an Auth0 namespace exceeds 64 chars, but that is Auth0's documented
  265. short-namespace territory; the limit matches email_claim and keeps the
  266. column bound meaningful.
  267. """
  268. if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-/]{0,63}", v):
  269. raise ValueError("Invalid claim name")
  270. return v
  271. def _validate_group_mapping(v: dict[str, str]) -> dict[str, str]:
  272. """#3107 — normalise and bound an IdP-group -> Bambuddy-group mapping.
  273. Values must reference Bambuddy group names; existence is checked against
  274. the database in the route handlers (same split as default_group_id), since
  275. the schema layer has no session. Keys are left as-is apart from stripping:
  276. IdP group values are opaque strings (DNs, UUIDs, names) and must match the
  277. claim byte-for-byte, so any normalisation beyond whitespace would silently
  278. break the lookup.
  279. Keys colliding case-insensitively are rejected: the sync matches the IdP
  280. side case-insensitively, so {"Admins": "Administrators", "admins":
  281. "Viewers"} would silently collapse to whichever entry the dict happens to
  282. keep last — a member of "Admins" could end up in Viewers. Rejecting the
  283. pair at save time (here, so the env path gets the same answer) turns an
  284. undiagnosable runtime behaviour into a form error. Two keys differing only
  285. by case and mapping to the SAME group are pointless but harmless, and are
  286. rejected too for the same reason: they read as a mistake.
  287. """
  288. if not isinstance(v, dict):
  289. raise ValueError("group_mapping must be a JSON object")
  290. if len(v) > 100:
  291. raise ValueError("group_mapping must have at most 100 entries")
  292. cleaned: dict[str, str] = {}
  293. seen_ci: dict[str, str] = {}
  294. for key, value in v.items():
  295. if not isinstance(key, str) or not key.strip():
  296. raise ValueError("group_mapping keys must be non-empty strings")
  297. if not isinstance(value, str) or not value.strip():
  298. raise ValueError("group_mapping values must be non-empty group names")
  299. k = key.strip()
  300. ci = k.lower()
  301. if ci in seen_ci:
  302. raise ValueError(
  303. f"group_mapping has two IdP groups differing only by case: "
  304. f"'{seen_ci[ci]}' and '{k}' — the sync matches case-insensitively, "
  305. f"so both cannot be honored"
  306. )
  307. seen_ci[ci] = k
  308. cleaned[k] = value.strip()
  309. return cleaned
  310. def _validate_icon_url(v: str | None) -> str | None:
  311. """Reject non-HTTPS icon URLs and SSRF-unsafe hosts.
  312. Delegates to the runtime SSRF guard ``assert_safe_public_https_url``
  313. so the Pydantic layer enforces the same allowlist as the fetcher —
  314. no policy drift between schema validation and SSRF check. Without
  315. this delegation the validator covered only ``is_private | is_loopback
  316. | is_link_local`` while the runtime additionally rejected numeric-
  317. encoded IPs, cloud-metadata endpoints, multicast, unspecified, and
  318. IPv4-mapped IPv6.
  319. Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/``
  320. and schemas avoid top-level imports from that layer (matches the
  321. existing pattern in ``_validate_issuer_url`` which lazy-imports
  322. ``ipaddress``).
  323. """
  324. if v is None:
  325. return v
  326. if not v.startswith("https://"):
  327. # Surface the same wording the runtime guard would use, but pre-
  328. # checked here so the user-facing error doesn't depend on the
  329. # runtime call path.
  330. raise ValueError("icon_url must start with https://")
  331. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  332. try:
  333. assert_safe_public_https_url(v)
  334. except ValueError as exc:
  335. raise ValueError(f"icon_url: {exc}") from exc
  336. return v
  337. def _validate_issuer_url(v: str | None) -> str | None:
  338. """Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
  339. An OIDC provider must be reachable over TLS on the public internet, so
  340. this uses the public-internet policy: private, loopback and link-local
  341. addresses are all rejected.
  342. Delegates to the runtime guard ``assert_safe_public_https_url`` for the
  343. same reason ``_validate_icon_url`` does — no policy drift between the
  344. schema layer and the fetcher. The hand-rolled version this replaced
  345. checked only ``is_private | is_loopback | is_link_local``, which left
  346. numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
  347. (``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
  348. able to express a target the policy meant to forbid. The guard's
  349. docstring already claimed the two were consistent; now they are.
  350. Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
  351. schemas avoid top-level imports from that layer.
  352. """
  353. if v is None:
  354. return v
  355. if not v.startswith("https://"):
  356. raise ValueError("issuer_url must start with https://")
  357. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  358. try:
  359. assert_safe_public_https_url(v)
  360. except ValueError as exc:
  361. # The guard's messages say "icon URL" — rewrite for this field so the
  362. # user sees the setting they actually submitted.
  363. detail = str(exc).replace("icon URL", "issuer_url")
  364. raise ValueError(detail) from exc
  365. return v
  366. def _validate_scopes(v: str | None) -> str | None:
  367. """Nit5: Require that the 'openid' scope is present.
  368. The OpenID Connect spec mandates the 'openid' scope; without it the
  369. response is plain OAuth2, not OIDC, and claims like sub/email are not
  370. guaranteed.
  371. """
  372. if v is None:
  373. return v
  374. scope_list = v.split()
  375. if "openid" not in scope_list:
  376. raise ValueError("scopes must include 'openid'")
  377. return v
  378. class OIDCProviderCreate(BaseModel):
  379. name: str = Field(..., max_length=100) # L-NEW-4
  380. issuer_url: str
  381. client_id: str = Field(..., max_length=256) # L-NEW-4
  382. client_secret: str = Field(..., max_length=512) # L-NEW-4: Fernet input bounded
  383. scopes: str = Field(default="openid email profile", max_length=256) # L-NEW-4
  384. is_enabled: bool = True
  385. auto_create_users: bool = False
  386. auto_link_existing_accounts: bool = False # M-2: conservative default, opt-in only
  387. email_claim: str = Field(default="email", max_length=64)
  388. require_email_verified: bool = True
  389. # #3107 — group sync config. group_mapping empty (default) = no sync.
  390. group_claim: str = Field(default="groups", max_length=64)
  391. group_mapping: dict[str, str] = Field(default_factory=dict)
  392. icon_url: str | None = None
  393. default_group_id: int | None = None
  394. is_autologin: bool = False # #1589 — at most one provider may carry this
  395. @field_validator("issuer_url")
  396. @classmethod
  397. def validate_issuer_url(cls, v: str) -> str:
  398. result = _validate_issuer_url(v)
  399. if result is None:
  400. raise ValueError("issuer_url is required")
  401. return result
  402. @field_validator("scopes")
  403. @classmethod
  404. def validate_scopes(cls, v: str) -> str:
  405. result = _validate_scopes(v)
  406. if result is None:
  407. raise ValueError("scopes is required")
  408. return result
  409. @field_validator("email_claim")
  410. @classmethod
  411. def validate_email_claim(cls, v: str) -> str:
  412. return _validate_email_claim_name(v)
  413. @field_validator("group_claim")
  414. @classmethod
  415. def validate_group_claim(cls, v: str) -> str:
  416. # Namespaced claims allowed here (Auth0 et al) — see _validate_group_claim_name.
  417. return _validate_group_claim_name(v)
  418. @field_validator("group_mapping")
  419. @classmethod
  420. def validate_group_mapping(cls, v: dict[str, str]) -> dict[str, str]:
  421. return _validate_group_mapping(v)
  422. @field_validator("icon_url")
  423. @classmethod
  424. def validate_icon_url(cls, v: str | None) -> str | None:
  425. return _validate_icon_url(v)
  426. # SEC-1: auto_link with email_claim='email' requires require_email_verified=True.
  427. # Fall B (require_email_verified=False + email_claim='email') accepts absent email_verified → account-takeover risk.
  428. # Fall C (custom claim != 'email') is safe: no email_verified gate on that path regardless of require_email_verified.
  429. @model_validator(mode="after")
  430. def check_auto_link_requires_verified(self) -> "OIDCProviderCreate":
  431. if self.auto_link_existing_accounts and self.email_claim == "email" and not self.require_email_verified:
  432. raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
  433. return self
  434. class OIDCProviderUpdate(IgnoresNullForRequiredColumns):
  435. __orm_table__ = "oidc_providers"
  436. name: str | None = Field(default=None, max_length=100)
  437. issuer_url: str | None = None
  438. @field_validator("issuer_url")
  439. @classmethod
  440. def validate_issuer_url(cls, v: str | None) -> str | None:
  441. return _validate_issuer_url(v)
  442. client_id: str | None = Field(default=None, max_length=256)
  443. client_secret: str | None = Field(default=None, max_length=512)
  444. scopes: str | None = Field(default=None, max_length=256)
  445. is_enabled: bool | None = None
  446. auto_create_users: bool | None = None
  447. auto_link_existing_accounts: bool | None = None
  448. email_claim: str | None = Field(default=None, max_length=64)
  449. require_email_verified: bool | None = None
  450. # #3107 — group sync config. None = leave unchanged, same as every other
  451. # optional field here; an explicit {} clears the mapping and disables sync.
  452. group_claim: str | None = Field(default=None, max_length=64)
  453. group_mapping: dict[str, str] | None = None
  454. icon_url: str | None = None
  455. default_group_id: int | None = None
  456. is_autologin: bool | None = None # #1589
  457. @field_validator("scopes")
  458. @classmethod
  459. def validate_scopes(cls, v: str | None) -> str | None:
  460. return _validate_scopes(v)
  461. @field_validator("email_claim")
  462. @classmethod
  463. def validate_email_claim(cls, v: str | None) -> str | None:
  464. if v is None:
  465. return None
  466. return _validate_email_claim_name(v)
  467. @field_validator("group_claim")
  468. @classmethod
  469. def validate_group_claim(cls, v: str | None) -> str | None:
  470. if v is None:
  471. return None
  472. return _validate_group_claim_name(v)
  473. @field_validator("group_mapping")
  474. @classmethod
  475. def validate_group_mapping(cls, v: dict[str, str] | None) -> dict[str, str] | None:
  476. if v is None:
  477. return None
  478. return _validate_group_mapping(v)
  479. @field_validator("icon_url")
  480. @classmethod
  481. def validate_icon_url(cls, v: str | None) -> str | None:
  482. return _validate_icon_url(v)
  483. # SEC-1 (schema-level): blocks only when auto_link=True + email_claim='email' + require_email_verified=False
  484. # arrive in the same request. email_claim=None means the request leaves it unchanged (still 'email' by default),
  485. # so that is also treated as 'email'. Partial updates spanning two requests are caught by the
  486. # Combined-State-Guard in the route handler after the setattr loop.
  487. @model_validator(mode="after")
  488. def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
  489. if (
  490. self.auto_link_existing_accounts is True
  491. and self.require_email_verified is False
  492. and (self.email_claim is None or self.email_claim == "email")
  493. ):
  494. raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
  495. return self
  496. class OIDCProviderResponse(BaseModel):
  497. id: int
  498. name: str
  499. issuer_url: str
  500. client_id: str
  501. scopes: str
  502. is_enabled: bool
  503. auto_create_users: bool
  504. auto_link_existing_accounts: bool = False
  505. email_claim: str = "email"
  506. require_email_verified: bool = True
  507. # #3107 — group sync config, echoed back so the settings UI can render it.
  508. group_claim: str = "groups"
  509. group_mapping: dict[str, str] = {}
  510. icon_url: str | None = None
  511. default_group_id: int | None = None
  512. is_autologin: bool = False # #1589
  513. # #2593 — the UI renders this provider read-only; without the flag it would
  514. # offer editable fields whose writes the API then refuses with 409.
  515. is_env_managed: bool = False
  516. # Set explicitly in the route handler from `icon_content_type is not None`
  517. # rather than `@computed_field` (project policy) or `icon_data is not None`
  518. # (would trigger an async lazy-load on the deferred BLOB column).
  519. # Required (no default) so Pydantic fails loudly if any code path skips
  520. # `_build_provider_response` and tries `model_validate(provider)` directly.
  521. has_icon: bool
  522. class Config:
  523. from_attributes = True
  524. class OIDCProviderPublicResponse(BaseModel):
  525. """#3107 — what the unauthenticated login page is allowed to see.
  526. GET /oidc/providers is public so the login page can render the SSO
  527. buttons, and it needs exactly four fields: id + name for the button,
  528. has_icon for the avatar, is_autologin for the redirect-on-mount (#1589).
  529. The full OIDCProviderResponse carries group_claim / group_mapping —
  530. which IdP group name maps to which Bambuddy group, including
  531. Administrators — and leaking that to anonymous visitors would tell
  532. anyone who can reach the login page exactly which IdP group to aim for.
  533. """
  534. id: int
  535. name: str
  536. has_icon: bool
  537. is_autologin: bool = False
  538. class Config:
  539. from_attributes = True
  540. class OIDCAuthorizeResponse(BaseModel):
  541. auth_url: str
  542. class OIDCExchangeRequest(BaseModel):
  543. oidc_token: str = Field(..., max_length=128)
  544. class OIDCLinkResponse(BaseModel):
  545. id: int
  546. provider_id: int
  547. provider_name: str
  548. provider_email: str | None = None
  549. created_at: str
  550. class EncryptionRowCounts(BaseModel):
  551. oidc_providers: int
  552. user_totp: int
  553. class EncryptionStatusResponse(BaseModel):
  554. key_configured: bool
  555. key_source: Literal["env", "file", "generated", "none"]
  556. legacy_plaintext_rows: EncryptionRowCounts
  557. encrypted_rows: EncryptionRowCounts
  558. # B4: filled by the endpoint after a sample-decrypt of one encrypted row,
  559. # so a wrong-key state (where key_configured=True but rows decrypt to junk)
  560. # is detected, not just the no-key case.
  561. decryption_broken: bool = False
  562. # B2: number of rows skipped during the last legacy re-encryption migration.
  563. # Filled from backend.app.core.database.get_migration_error_count().
  564. migration_error_count: int = 0