Repository navigation
refactor(user-mgmt): one UserService in autobot_shared, and the fork baseline fails on stale entries (#15068, #18088) - #18267
Conversation
📝 Walkthrough
Priority: ➖ Normal Merge Risk: 🔵 Low · up to Signing up or provisioning a user whose email belongs to one existing account and whose username belongs to another fails with a server error instead of a clear duplicate error. After this change, the same failure also affects the SLM service through SCIM provisioning and admin setup. The rest of the consolidation is low risk; this small fix is recommended before merging. Pre-merge checks |
|
…etails move to one helper, within the 30-line rule (#15068)
There was a problem hiding this comment.
Actionable comments posted: 1
ℹ️ Review info
⚙️ Run configuration
- Configuration used: Repository: mrveiss/AutoBot-AI/.coderabbit.yaml
- Review profile: ASSERTIVE
- Plan: Advanced
- Run ID:
4e2baf47-9ce0-43ba-a0c8-a0550c151503
⛔ Files ignored due to path filters (2)
repo_tests/python_file_size_ratchet_baseline.pyis excluded by!repo_tests/python_file_size_ratchet_baseline.pyscripts/python_file_size_known_large.pyis excluded by!scripts/python_file_size_known_large.py
📒 Files selected for processing (28)
autobot-backend/api/auth.pyautobot-backend/api/user_management/password_change.pyautobot-backend/api/user_management/password_change_test.pyautobot-backend/api/user_management/users.pyautobot-backend/api/user_management/users_duplicate_conflict_test.pyautobot-backend/user_management/middleware/rbac_middleware.pyautobot-backend/user_management/services/seed.pyautobot-backend/user_management/services/seed_test.pyautobot-backend/user_management/services/user_service.pyautobot-backend/user_management/services/user_service_conflict_test.pyautobot-backend/user_management/services/user_service_test.pyautobot-slm-backend/api/autobot_users.pyautobot-slm-backend/api/scim.pyautobot-slm-backend/api/slm_users.pyautobot-slm-backend/main.pyautobot-slm-backend/user_management/middleware/rbac_middleware.pyautobot-slm-backend/user_management/services/user_service.pyautobot_shared/user_management/user_service.pyautobot_shared/user_management/user_service_access.pyautobot_shared/user_management/user_service_base.pyautobot_shared/user_management/user_service_conflict.pyautobot_shared/user_management/user_service_errors.pyautobot_shared/user_management/user_service_lifecycle.pyautobot_shared/user_management/user_service_test.pyrepo_tests/one_rbac_middleware_18088_test.pyrepo_tests/one_user_service_15068_test.pyrepo_tests/symbol_fork_baseline.jsonrepo_tests/symbol_fork_ratchet_17312_test.py
💤 Files with no reviewable changes (1)
- repo_tests/symbol_fork_baseline.json
Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.
| async def _find_by_email_or_username(self, email: str, username: str) -> UserCore | None: | ||
| """Find any user (deleted included) holding this email or username.""" | ||
| user = self.user_model | ||
| result = await self.session.execute( | ||
| select(user).where( | ||
| or_( | ||
| func.lower(user.email) == email.lower(), | ||
| func.lower(user.username) == username.lower(), | ||
| ) | ||
| ) | ||
| ) | ||
| return result.scalar_one_or_none() |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Handle two different rows matching the duplicate lookup.
_find_by_email_or_username uses OR across two independently unique columns. Assume user A holds the email and user B holds the username. Then the query returns two rows. In that case scalar_one_or_none() raises MultipleResultsFound.
This lookup has two callers:
UserService._check_duplicate_userruns before everycreate_user. The signup handler inautobot-backend/api/auth.pymaps any non-DuplicateUserErrorexception to HTTP 500 "Registration failed". So the caller gets HTTP 500, not HTTP 409._resolve_conflictinuser_service_conflict.pyruns after a lost race. There, the same exception replaces the intendedDuplicateUserError.
After this PR, the SLM also runs both paths, so this behaviour now reaches SCIM provisioning and _ensure_admin_user. Take the first match and prefer the email match, so that field stays deterministic.
🐛 Proposed fix
--- "a/autobot_shared/user_management/user_service_base.py"
+++ "b/autobot_shared/user_management/user_service_base.py"
@@ -134,18 +134,20 @@
result = await self.session.execute(query)
return result.scalar_one_or_none()
async def _find_by_email_or_username(self, email: str, username: str) -> UserCore | None:
- """Find any user (deleted included) holding this email or username."""
+ """Find any user (deleted included) holding this email or username.
+
+ Two different rows can match (one per column); the email holder wins.
+ """
user = self.user_model
+ email_match = func.lower(user.email) == email.lower()
result = await self.session.execute(
- select(user).where(
- or_(
- func.lower(user.email) == email.lower(),
- func.lower(user.username) == username.lower(),
- )
- )
+ select(user)
+ .where(or_(email_match, func.lower(user.username) == username.lower()))
+ .order_by(email_match.desc())
+ .limit(1)
)
- return result.scalar_one_or_none()
+ return result.scalars().first()
async def _audit_log(
self,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def _find_by_email_or_username(self, email: str, username: str) -> UserCore | None: | |
| """Find any user (deleted included) holding this email or username.""" | |
| user = self.user_model | |
| result = await self.session.execute( | |
| select(user).where( | |
| or_( | |
| func.lower(user.email) == email.lower(), | |
| func.lower(user.username) == username.lower(), | |
| ) | |
| ) | |
| ) | |
| return result.scalar_one_or_none() | |
| async def _find_by_email_or_username(self, email: str, username: str) -> UserCore | None: | |
| """Find any user (deleted included) holding this email or username. | |
| Two different rows can match (one per column); the email holder wins. | |
| """ | |
| user = self.user_model | |
| email_match = func.lower(user.email) == email.lower() | |
| result = await self.session.execute( | |
| select(user) | |
| .where(or_(email_match, func.lower(user.username) == username.lower())) | |
| .order_by(email_match.desc()) | |
| .limit(1) | |
| ) | |
| return result.scalars().first() |
…8267 fixed (mrveiss#15068) The SSOT compliance check on mrveiss#18267's head reported both as STALE (matched 0 of 1).
…its projection (mrveiss#17225) Owner decision 2026-10-11 on D1: extend the existing Postgres audit_logs table rather than add audit_event. - Additive schema: category (NOT NULL, server default 'security'), actor, session_id, resource_ref and two indexes. Alembic 20261011_098 on the backend; add_audit_logs_canonical_columns on the SLM's slm_users database, which maps the same shared model. No existing row is rewritten; the pre-existing twelve columns are pinned unchanged. - One row constructor, autobot_shared/user_management/audit_rows.audit_row, used by the canonical service and by the shared RBAC denial, organisation and team writers, so both processes write one schema. The backend and SLM user_service.py still construct AuditLog directly; they move onto audit_row after mrveiss#18267, which rewrites both files. - services/audit/store.py: record() writes the row, then the Redis projection; every query is one SQL statement on audit_logs; rebuild_projection() reconstructs the projection. store_authority declares audit_trail POSTGRES with a REDIS projection. - .secrets.baseline: drop the entries for the two deleted legacy modules.
Thinking Path
#15068 listed four
user_managementfiles forked across both backends. Measured on currentmain, three of them had already converged:team_service(refactor(user-management): move byte-identical team_service to autobot_shared (#12647) #13164);organization_service(refactor(user-management): converge organization_service via injected models (#12647) #13201, which binds concrete models by injection);rbac_middleware(refactor(rbac): one permission-enforcement middleware and one transaction scope for both services (#18088, #15068) #18159).The one real fork left was
UserService: 900 and 863 lines, 128 diff lines, and its four error classes. This PR converges it the same wayOrganizationServicewas converged: one shared implementation, and each backend binds its ownUserand supplies its own hooks. Where the two copies differed, the union of checks won, and nothing was relaxed.Measuring also showed that two symbol-fork baseline entries had been stale on
mainwith the suite green. No test checked the real tree for stale entries. That gap is fixed here.What Changed
0b78abf262(tech-debt(user-mgmt): four user_management files remain forked after #12647 closed #15068): oneUserServiceatautobot_shared/user_management/user_service.py:42, split across six modules, none over 256 lines.autobot-backend/.../services/user_service.py:21supplies both hooks (invalidate_other_sessions→SessionService,reassign_memory→memory.ownership_reassign).autobot-slm-backend/.../services/user_service.py:17binds the model only, so the hooks are no-ops there.DuplicateUserError(field=)) moved toautobot_shared/user_management/user_service_errors.py. 13 importers are repointed (9 backend, 4 SLM).user_service_conflict.py, and both services use it.repo_tests/one_user_service_15068_test.py(11 tests, with contrast fixtures). It covers one definition of each error class and of the insert helper, and requires both bindings to subclass the shared class and add only the hooks.d256f74d3b,c7e0341b18(security(rbac): two byte-identical 595-line rbac_middleware copies, one per service #18088 housekeeping):RBACMiddlewareandrequire_all_permissionsare dropped fromrepo_tests/symbol_fork_baseline.json. They had been satisfied since refactor(rbac): one permission-enforcement middleware and one transaction scope for both services (#18088, #15068) #18159.test_no_stale_baseline_entries(symbol_fork_ratchet_17312_test.py) runs the real tree. Restoring theRBACMiddlewareline fails it, and the current tree passes.UserServiceerror classes are also dropped.UserServiceitself stays listed, because the shared class and its two bindings share the name, the same shape asOrganizationService.Behaviour changes (none relaxes a check)
sessions_revoked_fromandsessions_invalidatedin both services (user_service_lifecycle.py:81-82). Nothing is removed or renamed.sessions_invalidatedis 0 in the SLM, which does not invalidate sessions.DuplicateUserErrorinstead of a rawIntegrityError, the stricter backend behaviour.models/core_test.py's subprocess-per-class loader, before and after:Userf61052165d5fOrganizationf3a57afa3cb1Usercf99c6fc82a9Organization65d767cfb257Kept semantically identical on purpose
get_user_permissionsdoesselect(role.Permission), an attributeRoledoes not have, so the DB permission lookup raises and the middleware denies. Both forks had this line. It is moved semantically identical (now read through apermission_modellocal,user_service_access.py:175-178): fixing it grants permissions that are denied today, so it keeps# type: ignore[attr-defined] # #18259. The owner decided on 2026-10-11 that #18259 is fixed separately, with the newly granted set listed for review.Acceptance criteria (#15068)
team_service: met on main by refactor(user-management): move byte-identical team_service to autobot_shared (#12647) #13164. One definition atautobot_shared/user_management/team_service.py:66, and both backends hold 32-line shims.rbac_middleware/user_serviceconverged with the union of checks:rbac_middleware: met on main by refactor(rbac): one permission-enforcement middleware and one transaction scope for both services (#18088, #15068) #18159 (autobot_shared/.../middleware/rbac_middleware.py:130).user_service: this PR.UserService's permission lookup is kept semantically identical (above). No decision changes.organization_service: resolved by injection on main (refactor(user-management): converge organization_service via injected models (#12647) #13201). Shims are atorganization_service.py:28in each backend.Verification
autobot_shared/user_management/plus the new guard, the fork ratchet, the rbac guard, the size ratchet and ceiling parity: 200 passed.reach_scope_claim_17844_test, run alone: 10 passed.ownership_reassign: 185 passed;models/core_test.py: 10 passed;main, filed as test(slm): test_client_secret_stored_in_config_jsonb fails on main — 'user_management.models' is not a package #18265.Not done, stated rather than implied
require_permission) needs the permission-source decision. The owner chose a strict union on 2026-10-11, and it is filed as child authz: one enum-typed require_permission over one strict-union evaluator (#18088 AC3) #18263 (blocked by security(rbac): UserService.get_user_permissions reads role.Permission, which does not exist — the DB-backed permission path raises and the middleware denies #18259). security(rbac): two byte-identical 595-line rbac_middleware copies, one per service #18088 AC4 (the refactor(auth): unify 4 auth/RBAC layers — SLM has zero shared code with main backend (security drift risk) #6511 re-check) found refactor(auth): unify 4 auth/RBAC layers — SLM has zero shared code with main backend (security drift risk) #6511 closed with criteria unmet, so the remainder is filed as auth: #6511 remainder — deny audit from both backends, cross-service parity, service_auth_enforcement JWT path #18264. That is why this PR is Refs security(rbac): two byte-identical 595-line rbac_middleware copies, one per service #18088, not Closes.Closes #15068
Refs #18088
Model Used
Claude Opus 5.5 (master session: decisions, rebase, verification, PR). The implementation was done by a Claude Opus subagent.
🤖 Generated with Claude Code
Summary by CodeRabbit