Skip to content

refactor(user-mgmt): one UserService in autobot_shared, and the fork baseline fails on stale entries (#15068, #18088) - #18267

Merged
mrveiss merged 4 commits into
mainfrom
issue-15068-user-mgmt-forks
Oct 11, 2026
Merged

mrveiss merged 4 commits into
mainfrom
issue-15068-user-mgmt-forks

Conversation

@mrveiss

@mrveiss mrveiss commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Thinking Path

#15068 listed four user_management files forked across both backends. Measured on current main, three of them had already converged:

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 way OrganizationService was converged: one shared implementation, and each backend binds its own User and 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 main with the suite green. No test checked the real tree for stale entries. That gap is fixed here.

What Changed

Behaviour changes (none relaxes a check)

  • Audit payload, the one visible change. The password-change audit entry now writes both sessions_revoked_from and sessions_invalidated in both services (user_service_lifecycle.py:81-82). Nothing is removed or renamed. sessions_invalidated is 0 in the SLM, which does not invalidate sessions.
  • SLM duplicate-signup race. It now raises DuplicateUserError instead of a raw IntegrityError, the stricter backend behaviour.
  • Schema is unchanged. Measured per class via models/core_test.py's subprocess-per-class loader, before and after:
class columns relationships fingerprint
backend User 18 11 f61052165d5f
backend Organization 24 6 f3a57afa3cb1
SLM User 18 6 cf99c6fc82a9
SLM Organization 11 4 65d767cfb257

Kept semantically identical on purpose

get_user_permissions does select(role.Permission), an attribute Role does 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 a permission_model local, 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)

Verification

  • On the rebased head: 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.
  • Pre-rebase:

Not done, stated rather than implied

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

  • New Features
    • Standardised user account management across both backends, including sign-up, authentication, profile updates, password changes, role management, and account lifecycle operations.
    • Improved handling of duplicate accounts during creation, including concurrent sign-up attempts.

@coderabbitai

coderabbitai Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

📝 Walkthrough
📝 Walkthrough
📝 Walkthrough

Walkthrough

The change moves user-management operations into autobot_shared and reduces both backend UserService implementations to model-specific subclasses. Backend call sites now import shared error classes. Tests cover shared behaviour and repository checks for duplicate implementations.

Changes

Shared user-management consolidation

Layer / File(s) Summary
Shared service foundation and user operations
autobot_shared/user_management/user_service_base.py, autobot_shared/user_management/user_service.py, autobot_shared/user_management/user_service_conflict.py, autobot_shared/user_management/user_service_errors.py, autobot_shared/user_management/user_service_test.py
The shared base adds model binding, lookup, password, hook, and audit helpers. The shared service adds user creation, listing, and profile updates, using duplicate-safe insertion and shared error types.
Shared access and lifecycle operations
autobot_shared/user_management/user_service_access.py, autobot_shared/user_management/user_service_lifecycle.py, autobot_shared/user_management/user_service_test.py
Shared mixins add authentication, role and permission access, password changes, activation, and deletion. Tests cover hooks, audit details, and deletion behaviour when memory reassignment fails. The permission lookup continues to access role.Permission, which the summary identifies as absent on Role.
Backend bindings and repository checks
autobot-backend/user_management/services/user_service.py, autobot-slm-backend/user_management/services/user_service.py, autobot-backend/api/..., autobot-backend/user_management/services/..., autobot-slm-backend/api/..., autobot-slm-backend/main.py, autobot-slm-backend/user_management/middleware/rbac_middleware.py, autobot-backend/user_management/middleware/rbac_middleware.py, repo_tests/one_user_service_15068_test.py, repo_tests/one_rbac_middleware_18088_test.py, repo_tests/symbol_fork_baseline.json, repo_tests/symbol_fork_ratchet_17312_test.py
Both backend services bind the shared service to their local user model; the backend adds session-invalidation and memory-reassignment hooks. API and service callers import exception classes from the shared error module. Repository tests check backend bindings, shared definitions, and stale symbol-fork baseline entries.




Priority: ➖ Normal

Merge Risk: 🔵 Low · up to 44b34

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 | Passed 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check Passed The PR meets the coding requirements in #15068. It adds one shared UserService implementation in autobot_shared/user_management/, binds the backend and SLM User models, and keeps backend-specifi…
Out of Scope Changes check Passed The changes stay within the scope of #15068. The baseline removals record symbols that are already converged, and test_no_stale_baseline_entries supports the fork-convergence guards. Import updates,…
Docstring Coverage Passed Docstring coverage is 80.56% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 72 functions across 27 files.
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly summarises the main changes: consolidating UserService in autobot_shared and updating the fork baseline stale-entry check. It is specific and related to the pull request.

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR



📝 Generate docstrings
  • Commit to this branch
  • Create a new PR





🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR





  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…etails move to one helper, within the 30-line rule (#15068)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
📥 Commits

Reviewing files that changed from the base of the PR and between 8faa865 and 44b3404.

⛔ Files ignored due to path filters (2)
  • repo_tests/python_file_size_ratchet_baseline.py is excluded by !repo_tests/python_file_size_ratchet_baseline.py
  • scripts/python_file_size_known_large.py is excluded by !scripts/python_file_size_known_large.py
📒 Files selected for processing (28)
  • autobot-backend/api/auth.py
  • autobot-backend/api/user_management/password_change.py
  • autobot-backend/api/user_management/password_change_test.py
  • autobot-backend/api/user_management/users.py
  • autobot-backend/api/user_management/users_duplicate_conflict_test.py
  • autobot-backend/user_management/middleware/rbac_middleware.py
  • autobot-backend/user_management/services/seed.py
  • autobot-backend/user_management/services/seed_test.py
  • autobot-backend/user_management/services/user_service.py
  • autobot-backend/user_management/services/user_service_conflict_test.py
  • autobot-backend/user_management/services/user_service_test.py
  • autobot-slm-backend/api/autobot_users.py
  • autobot-slm-backend/api/scim.py
  • autobot-slm-backend/api/slm_users.py
  • autobot-slm-backend/main.py
  • autobot-slm-backend/user_management/middleware/rbac_middleware.py
  • autobot-slm-backend/user_management/services/user_service.py
  • autobot_shared/user_management/user_service.py
  • autobot_shared/user_management/user_service_access.py
  • autobot_shared/user_management/user_service_base.py
  • autobot_shared/user_management/user_service_conflict.py
  • autobot_shared/user_management/user_service_errors.py
  • autobot_shared/user_management/user_service_lifecycle.py
  • autobot_shared/user_management/user_service_test.py
  • repo_tests/one_rbac_middleware_18088_test.py
  • repo_tests/one_user_service_15068_test.py
  • repo_tests/symbol_fork_baseline.json
  • repo_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.

Comment on lines +137 to +148
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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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_user runs before every create_user. The signup handler in autobot-backend/api/auth.py maps any non-DuplicateUserError exception to HTTP 500 "Registration failed". So the caller gets HTTP 500, not HTTP 409.
  • _resolve_conflict in user_service_conflict.py runs after a lost race. There, the same exception replaces the intended DuplicateUserError.

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.

Suggested change
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()

@mrveiss
mrveiss merged commit 8aa089b into main Oct 11, 2026
54 of 83 checks passed
@mrveiss
mrveiss deleted the issue-15068-user-mgmt-forks branch October 11, 2026 17:21
neoneye pushed a commit to agent-memory-atlas-archive/mrveiss--AutoBot-AI that referenced this pull request Oct 13, 2026
…8267 fixed (mrveiss#15068)

The SSOT compliance check on mrveiss#18267's head reported both as STALE (matched 0 of 1).
neoneye pushed a commit to agent-memory-atlas-archive/mrveiss--AutoBot-AI that referenced this pull request Oct 13, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tech-debt(user-mgmt): four user_management files remain forked after #12647 closed

1 participant