Skip to content

feat: improve SCIM implementation#2351

Merged
wilsonrivera merged 8 commits intomainfrom
wilson/eng-8506-wun-q425-01
Nov 22, 2025
Merged

feat: improve SCIM implementation#2351
wilsonrivera merged 8 commits intomainfrom
wilson/eng-8506-wun-q425-01

Conversation

@wilsonrivera
Copy link
Copy Markdown
Contributor

@wilsonrivera wilsonrivera commented Nov 21, 2025

Summary by CodeRabbit

  • New Features

    • Members now have an independent active/inactive flag; invitations record when they were last sent.
    • Added a dedicated invite service that centralizes invitation flows and email delivery.
  • Improvements

    • SCIM API expanded with richer create/patch flows, audit logging, and optional mailer integration.
    • Better handling for user lookups and invitation resend throttling; new audit actions for SCIM events.
  • Tests

    • SCIM tests updated to cover invitation-based flows and authenticator-driven acceptance.

✏️ Tip: You can customize this high-level summary in your review settings.

Checklist

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Nov 21, 2025

Walkthrough

Adds organization member active flag and invitation last-sent timestamp; introduces a transactional UserInviteService and Keycloak helper; refactors invite and SCIM flows to use the service with optional mailer and audit logging; updates repositories, types, migration, tests, and audit log actions.

Changes

Cohort / File(s) Summary
Migrations & Schema
controlplane/migrations/0133_foamy_vargas.sql, controlplane/migrations/meta/_journal.json, controlplane/src/db/schema.ts, controlplane/src/db/models.ts, controlplane/src/types/index.ts
Adds last_sent_at to organization_invitations and active (boolean, not null, default true) to organizations_members; migration journal updated; AuditLogFullAction extended with SCIM actions; OrganizationInvitationDTO gains lastSentAt.
Invitation Service & Keycloak
controlplane/src/core/services/UserInviteService.ts, controlplane/src/core/services/Keycloak.ts
Adds UserInviteService encapsulating transactional invite flow (advisory lock, org validation, Keycloak sync/find/create, create/resend invitation with 30‑min guard, mailer/Keycloak actions) and adds Keycloak.findUserByEmail(...).
Repositories
controlplane/src/core/repositories/OrganizationInvitationRepository.ts, controlplane/src/core/repositories/OrganizationRepository.ts, controlplane/src/core/repositories/UserRepository.ts
Propagates lastSentAt in invitation DTOs and adds updateLastSentToNow(); membership queries/DTOs now use organizations_members.active and filter by active = true; adds setOrganizationMemberActive(); removes UserRepository.updateUser(...).
Controllers & Handlers
controlplane/src/core/bufservices/user/inviteUser.ts, controlplane/src/core/controllers/scim.ts, controlplane/src/core/controllers/auth.ts
inviteUser handler delegates to UserInviteService; SCIM controller expanded for create/patch/update flows, uses the service, accepts optional mailer, and emits audit logs; auth membership checks now require active = true.
Server Wiring & Errors
controlplane/src/core/build-server.ts, controlplane/src/core/errors/errors.ts
Wires mailerClient into SCIM controller registration options; isPublicError signature widened from Error to unknown.
Tests & UI
controlplane/test/scim.test.ts, studio/src/components/audit-log-table.tsx
Tests updated to exercise invitation-based flows, export/use TestAuthenticator, and spy Keycloak email actions; audit-log UI includes scim.organization_invitation_created in display conditions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Areas needing extra attention:
    • Transactional advisory lock usage and rollback semantics in UserInviteService.
    • Resend-rate (30‑minute) guard and correctness of updateLastSentToNow.
    • Keycloak synchronization, find/create edge cases, and PublicError mapping.
    • All call sites affected by member active semantics and the removal of UserRepository.updateUser.
    • SCIM create/patch flows, audit log events, and optional mailer wiring.

Possibly related PRs

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: improve SCIM implementation' is clear, concise, and directly reflects the main changes in the pull request, which comprehensively enhance the SCIM controller with new features including user invitation orchestration, auditing, error handling, and expanded endpoint functionality.
✨ Finishing touches
  • 📝 Generate docstrings

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
controlplane/src/core/repositories/OrganizationRepository.ts (1)

242-254: isMemberOf does not constrain membership to the given organization

The join currently does:

.from(organizationsMembers)
.innerJoin(organizations, eq(organizations.id, input.organizationId))
.innerJoin(users, eq(users.id, organizationsMembers.userId))
.where(and(eq(users.id, input.userId), eq(organizationsMembers.active, true)))

organizations.id is compared only to the literal input.organizationId, never to organizationsMembers.organizationId. As long as the organization exists and the user has any active membership in any org, this query will return a row and isMemberOf will incorrectly return true.

You likely want to either:

  • Join on the membership relation and filter by org:
.from(organizationsMembers)
.innerJoin(organizations, eq(organizations.id, organizationsMembers.organizationId))
.innerJoin(users, eq(users.id, organizationsMembers.userId))
.where(
  and(
    eq(users.id, input.userId),
    eq(organizationsMembers.organizationId, input.organizationId),
    eq(organizationsMembers.active, true),
  ),
);

or use an equivalent condition in where. This is a behavioral bug and should be fixed before relying on isMemberOf for authorization or SCIM logic.

controlplane/src/core/controllers/scim.ts (2)

157-260: Use organization member's active field consistently across all GET endpoints

The inconsistency is confirmed. Your GET /Users list endpoint returns:

active: keycloakUsers[0].enabled ?? true,

while GET /Users/:userID returns:

active: user.active,

This divergence is problematic because PUT and PATCH both treat the org member's user.active field as the source of truth for the active status (e.g., line 456 compares user.active !== active and updates accordingly). However, the list endpoint ignores this and pulls from Keycloak instead, causing the same user to report different active values depending on which endpoint is called.

Update the list endpoint to use member.active and user.active (instead of keycloakUsers[0].enabled) on both the filtered (line 214) and unfiltered (line 244) branches to maintain consistency.


317-396: HTTP and SCIM status codes must be aligned per RFC 7644

In the POST /Users handler (lines 374-379), error status codes are misaligned:

if (isPublicError(err)) {
  return res.code(400).send(
    ScimError({
      detail: err.message,
      status: err.code === EnumStatusCode.ERR_ALREADY_EXISTS ? 409 : 500,
    }),
  );
}

Issues confirmed:

  • ERR_ALREADY_EXISTS: HTTP status is 400, SCIM status is 409 — violates RFC 7644 Section 3.12, which requires these to match. Should be HTTP 409.
  • Other PublicError codes (ERR_NOT_FOUND, ERR_LIMIT_REACHED, etc.): HTTP status is 400, SCIM status is 500 — violates alignment requirement. ERR_NOT_FOUND should be 404; ERR_LIMIT_REACHED should be 429 or 400 with appropriate scimType.

Correct the error handling to align HTTP and SCIM status codes:

  • Return HTTP 409 for ERR_ALREADY_EXISTS with SCIM status 409 (conflict/uniqueness).
  • Return HTTP 404 for ERR_NOT_FOUND with SCIM status 404.
  • Return HTTP 400 (or 429) for ERR_LIMIT_REACHED with SCIM status matching the HTTP code.
🧹 Nitpick comments (10)
controlplane/src/db/models.ts (1)

100-177: SCIM audit actions wiring looks consistent with existing model

The new AuditLogFullAction variants for SCIM and namespace_proposal_config.updated integrate cleanly into the union and match how audit actions are modeled elsewhere. Only minor nit: scim.organization_invitation_created uses an underscore before created whereas most other actions follow resource.action (organization_invitation.created). If consistency with existing naming matters for downstream consumers or analytics, consider aligning the naming scheme; otherwise this is fine as-is.

controlplane/src/db/schema.ts (1)

1388-1401: New active and lastSentAt columns are modeled correctly; consider active semantics for aggregations

  • organizationsMembers.active: boolean('active').notNull().default(true) and organizationInvitations.lastSentAt: timestamp('last_sent_at', { withTimezone: true }) are modeled in line with existing schema (e.g., users.active, other timestamps) and match the repository usage in this PR.
  • With the new active flag on organization_members, some read paths now filter on active = true while others (e.g., memberCount) still count all memberships. Double-check whether the intended behavior is “all members ever” vs “currently active members” and align those callers if necessary.

Also applies to: 1591-1607

controlplane/src/core/repositories/OrganizationRepository.ts (1)

259-291: Active membership flag is wired through DTOs correctly; tighten a couple of behaviors

  • memberships, getOrganizationMember, getOrganizationMemberByEmail, and getMembers now all surface organizationsMembers.active into DTOs. This aligns nicely with the new per-membership activity concept and keeps repo code data-focused, with authorization still handled at the service layer (as per prior learnings).
  • In memberships, filtering on organizationsMembers.active = true is consistent with the idea that only active memberships should be treated as current when building auth context and RBAC evaluators.

A few follow-ups to consider:

  1. Clarify which paths should ignore inactive memberships

    • memberCount and getOrganizationAdmins (which calls getMembers) currently include inactive members. If SCIM deactivation is meant to remove users from “admin” lists and member counts, you may want to add eq(organizationsMembers.active, true) to those queries as well. If not, a short comment near those methods clarifying intent (“counts all, including inactive”) would help avoid regressions later.
  2. setOrganizationMemberActive execution semantics

    public setOrganizationMemberActive(input: { id: string; organizationId: string; active: boolean }) {
      return this.db
        .update(organizationsMembers)
        .set({ active: input.active })
        .where(and(eq(organizationsMembers.organizationId, input.organizationId), eq(organizationsMembers.id, input.id)));
    }

    This returns a Drizzle update query. It will only run if call sites await the return value (since Drizzle’s query builders are PromiseLike), but the method itself is not async, unlike most other mutators in this repository.

    For consistency and to make misuse harder, consider making it explicitly async and executing it here:

    public async setOrganizationMemberActive(input: { id: string; organizationId: string; active: boolean }) {
      await this.db
        .update(organizationsMembers)
        .set({ active: input.active })
        .where(
          and(
            eq(organizationsMembers.organizationId, input.organizationId),
            eq(organizationsMembers.id, input.id),
          ),
        );
    }

    This keeps call sites from accidentally forgetting to await the query.

  3. Group resolution and inactive members

    getOrganizationMemberGroups does not filter on organizationsMembers.active, which is fine if it’s only called from flows that already restrict to active members (e.g., memberships, getMembers). If you plan to use it in contexts where a user might be inactive in the organization, consider either adding an active = true condition or documenting that callers must enforce that themselves.

Also applies to: 360-395, 398-439, 441-492, 494-505, 507-512, 1592-1617

controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)

165-208: Invitation creation & lastSentAt updates are mostly solid; consider tightening update scope

  • inviteUser now bails out of group insertion when either the insert yields no rows or input.groups.length === 0. That safely supports “invite without groups” and avoids inserting an empty values([]) set.

  • updateLastSentToNow correctly updates lastSentAt only for non-accepted invitations for the specified user and organization:

    .where(
      and(
        eq(organizationInvitations.organizationId, organizationId),
        eq(organizationInvitations.userId, userId),
        eq(organizationInvitations.accepted, false),
      ),
    )

    which is exactly what the resend-throttling logic in UserInviteService needs.

One small robustness improvement: if your data model ever allowed multiple pending invitations per (organizationId, userId), this update would touch all of them while getPendingOrganizationInvitation only reads one (the earliest by createdAt). To keep the write/read symmetry tight, you could optionally narrow updateLastSentToNow by invitation ID when you have it available (e.g., pass invitationId from the pending-invitation lookup). Not required if you guarantee at most one pending invite per org/user pair.

Also applies to: 263-275

controlplane/src/core/bufservices/user/inviteUser.ts (1)

9-42: Good service extraction and transactional invite flow

  • Delegating the invitation logic to UserInviteService inside a single opts.db.transaction is a solid refactor: the handler now focuses on auth, wiring, and audit logging, while the service encapsulates the complex invite/user/group/Keycloak logic.
  • Injecting db: tx, logger, keycloakRealm, keycloak, and mailer into the service matches how the service is designed in the rest of the PR and keeps concerns separated.
  • Audit logging still records organization_invitation.created with organizationSlug from the auth context, which is appropriate for this non-SCIM path.

If the InviteUserRequest proto later grows optional fields like first/last name or a password that the service can use, consider threading those through to service.inviteUser here as well to avoid having multiple invite entry points behave differently.

Also applies to: 44-47

controlplane/test/scim.test.ts (1)

116-159: Make the pending invitations assertion resilient to existing state

This test assumes there are zero pending invitations before creating the new user and therefore asserts totalCount === 1. Because the DB and server are shared across tests in this describe, any earlier test that leaves a pending invitation will make this brittle.

Consider either:

  • Capturing the initial totalCount before the POST and asserting that it increased by one, or
  • Filtering pendingInvitations by email and asserting that the new email is present, without hard-coding totalCount.
controlplane/src/core/services/UserInviteService.ts (4)

66-77: Member limit check should not rely on truthiness of limit

limit is computed then used as:

const limit = usersFeature?.limit === -1 ? undefined : usersFeature?.limit;
if (limit && memberCount >= limit) {
  throw new PublicError(EnumStatusCode.ERR_LIMIT_REACHED, ...);
}

If limit is ever 0, this condition will not trigger and the organization will be treated as having no limit. Safer is to check explicitly for undefined:

if (limit !== undefined && memberCount >= limit) {
  ...
}

This keeps semantics correct even if a “zero‑member” plan is ever introduced.


96-118: Consider auto‑provisioning the DB user when Keycloak user exists but DB user is missing

Currently:

  • If the Keycloak user is missing, you create it and immediately add a DB user.
  • If the Keycloak user exists but userRepo.byId returns null, you throw PublicError('User not found').

If there are valid scenarios where a Keycloak user may pre‑exist without a DB row (e.g., manually created in Keycloak or migrated from another system), this will prevent invitations from being sent.

A more forgiving behavior would be to create the DB user in that second case as well, mirroring the “user doesn’t exist in Keycloak” branch, and only throw if that insertion fails.


141-162: Group validation logic is strict for empty groups arrays

The logic:

const groups: string[] = [];
if (input.groups) {
  ...
}
if (input.groups && groups.length === 0) {
  throw new PublicError(EnumStatusCode.ERR, 'No group was provided');
}

Means that if input.groups is an empty array, you’ll throw 'No group was provided'. That’s a valid design, but differs from the SCIM controller, which currently never supplies groups into inviteUser (so this branch is effectively unused).

If you plan to wire SCIM groups into inviteUser, consider whether an empty array should be allowed (no groups) or rejected, and encode that explicitly (e.g., if (input.groups && input.groups.length === 0) or by validating at the controller layer).


188-236: Throttle interval comment doesn’t match implementation; also only applies to mailer path

Inside #sendInvitation:

if (pendingInvitation?.lastSentAt && pendingInvitation.lastSentAt.getTime() + 1000 * 60 * 30 > Date.now()) {
  // We are not sending the invitation more than once an hour
  return;
}
  • 1000 * 60 * 30 is 30 minutes, not one hour, so the comment is misleading.
  • This throttle only applies to the custom mailer path. If the user has no memberships yet, you always send a new Keycloak “execute actions” email regardless of lastSentAt.

If the intent is a 30‑minute throttle for custom mail plus unlimited Keycloak emails, just fix the comment. If you actually want a one‑hour throttle or want it applied to both paths, consider adjusting the multiplier and/or factoring the lastSentAt check up a level.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0cdc021 and a6e6369.

📒 Files selected for processing (17)
  • controlplane/migrations/0133_foamy_vargas.sql (1 hunks)
  • controlplane/migrations/meta/_journal.json (1 hunks)
  • controlplane/src/core/bufservices/user/inviteUser.ts (2 hunks)
  • controlplane/src/core/build-server.ts (1 hunks)
  • controlplane/src/core/controllers/auth.ts (2 hunks)
  • controlplane/src/core/controllers/scim.ts (8 hunks)
  • controlplane/src/core/errors/errors.ts (1 hunks)
  • controlplane/src/core/repositories/OrganizationInvitationRepository.ts (4 hunks)
  • controlplane/src/core/repositories/OrganizationRepository.ts (6 hunks)
  • controlplane/src/core/repositories/UserRepository.ts (0 hunks)
  • controlplane/src/core/services/Keycloak.ts (1 hunks)
  • controlplane/src/core/services/UserInviteService.ts (1 hunks)
  • controlplane/src/db/models.ts (1 hunks)
  • controlplane/src/db/schema.ts (2 hunks)
  • controlplane/src/types/index.ts (1 hunks)
  • controlplane/test/scim.test.ts (9 hunks)
  • studio/src/components/audit-log-table.tsx (1 hunks)
💤 Files with no reviewable changes (1)
  • controlplane/src/core/repositories/UserRepository.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-29T10:28:04.846Z
Learnt from: JivusAyrus
Repo: wundergraph/cosmo PR: 2156
File: controlplane/src/core/repositories/SubgraphRepository.ts:1749-1751
Timestamp: 2025-08-29T10:28:04.846Z
Learning: In the controlplane codebase, authentication and authorization checks (including organization scoping) are handled at the service layer in files like unlinkSubgraph.ts before calling repository methods. Repository methods like unlinkSubgraph() in SubgraphRepository.ts can focus purely on data operations without redundant security checks.

Applied to files:

  • controlplane/src/core/repositories/OrganizationRepository.ts
  • controlplane/src/core/controllers/auth.ts
📚 Learning: 2025-09-10T11:15:52.157Z
Learnt from: JivusAyrus
Repo: wundergraph/cosmo PR: 2156
File: controlplane/src/core/repositories/ProposalRepository.ts:562-572
Timestamp: 2025-09-10T11:15:52.157Z
Learning: The getLatestCheckForProposal function in controlplane/src/core/repositories/ProposalRepository.ts is only called during proposal creation or updates, so proposal match error checking (hasProposalMatchError) is not needed since the proposal is being modified itself rather than being matched against.

Applied to files:

  • controlplane/src/core/repositories/OrganizationInvitationRepository.ts
🧬 Code graph analysis (7)
controlplane/src/core/services/UserInviteService.ts (8)
controlplane/src/core/services/Keycloak.ts (1)
  • Keycloak (8-442)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/repositories/OrganizationRepository.ts (3)
  • OrganizationRepository (50-1670)
  • memberCount (341-358)
  • memberships (259-339)
controlplane/src/core/repositories/UserRepository.ts (1)
  • UserRepository (18-178)
controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)
  • OrganizationInvitationRepository (12-276)
controlplane/src/core/errors/errors.ts (1)
  • PublicError (17-17)
controlplane/src/core/repositories/OrganizationGroupRepository.ts (1)
  • OrganizationGroupRepository (9-356)
controlplane/src/types/index.ts (1)
  • OrganizationInvitationDTO (313-319)
controlplane/src/core/repositories/OrganizationRepository.ts (1)
controlplane/src/db/schema.ts (3)
  • users (1134-1140)
  • organizationsMembers (1387-1409)
  • organizations (1261-1284)
controlplane/src/core/controllers/auth.ts (1)
controlplane/src/db/schema.ts (1)
  • organizationsMembers (1387-1409)
controlplane/src/core/controllers/scim.ts (4)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/services/UserInviteService.ts (1)
  • UserInviteService (15-237)
controlplane/src/core/repositories/AuditLogRepository.ts (2)
  • AuditLogRepository (28-137)
  • AddAuditLogInput (6-23)
controlplane/src/core/errors/errors.ts (1)
  • isPublicError (33-35)
controlplane/test/scim.test.ts (1)
controlplane/src/core/test-util.ts (1)
  • TestAuthenticator (223-226)
controlplane/src/core/bufservices/user/inviteUser.ts (1)
controlplane/src/core/services/UserInviteService.ts (1)
  • UserInviteService (15-237)
controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)
controlplane/src/db/schema.ts (1)
  • organizationInvitations (1591-1615)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: Analyze (go)
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: build_test
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: build_push_image
🔇 Additional comments (17)
controlplane/src/core/controllers/auth.ts (2)

4-4: LGTM: Import added for new active filtering logic.

The import of and from drizzle-orm is necessary for the compound where clause added at Line 274.


274-274: LGTM: Active membership filtering properly implemented.

The addition of eq(organizationsMembers.active, true) ensures that only active organization memberships are considered when determining if a user already belongs to an organization. This correctly aligns with the new active field introduced in the schema and prevents treating inactive memberships as valid.

studio/src/components/audit-log-table.tsx (1)

80-82: LGTM: SCIM invitation audit action properly integrated.

The addition of auditAction === "scim.organization_invitation_created" appropriately extends the UI formatting logic to handle SCIM-based invitation audit events consistently with existing invitation actions.

controlplane/src/core/errors/errors.ts (1)

33-35: LGTM: Type signature appropriately widened.

Changing the parameter type from Error to unknown is a standard best practice for type guards. This allows callers to pass any error type without type assertions while preserving the runtime instanceof check.

controlplane/migrations/meta/_journal.json (1)

935-942: LGTM: Migration journal entry properly added.

The new journal entry follows the established pattern and correctly references the corresponding migration file 0133_foamy_vargas.sql.

controlplane/src/types/index.ts (1)

318-318: LGTM: DTO properly extended with invitation tracking field.

The optional lastSentAt field correctly represents the new database column and aligns with the invitation tracking functionality introduced in this PR.

controlplane/migrations/0133_foamy_vargas.sql (1)

1-2: LGTM: Schema migration properly structured.

Both column additions are non-breaking:

  • last_sent_at is nullable, appropriately handling existing invitation records.
  • active has a sensible default of true with NOT NULL constraint, ensuring existing members remain active.
controlplane/src/core/build-server.ts (1)

463-463: LGTM: Mailer properly wired to SCIM controller.

The mailer client is correctly passed to the SCIM controller registration, enabling email-based invitation functionality when SMTP is configured.

controlplane/src/core/services/Keycloak.ts (1)

95-104: LGTM: User lookup method properly implemented.

The findUserByEmail method is correctly implemented with:

  • Exact email matching to avoid false positives.
  • Efficient single-result query (max: 1).
  • Consistent realm fallback pattern matching other methods in the class.
  • Clear return semantics (user object or undefined).
controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)

111-149: Exposing lastSentAt on pending invitations aligns with resend throttling

Selecting schema.organizationInvitations.lastSentAt and threading it into the OrganizationInvitationDTO gives the UserInviteService enough context to avoid resending invitations too frequently. The null/undefined handling is straightforward:

lastSentAt: orgMember[0].lastSentAt || undefined,

Assuming the DTO is typed to accept Date | undefined, this is consistent with how other timestamp fields are handled in the codebase (e.g., using toISOString() only when serializing externally). Looks good.

controlplane/test/scim.test.ts (3)

7-8: Authenticator wiring in tests looks correct

Importing TestAuthenticator and pulling setupDetails.authenticator into a shared authenticator variable matches the intended pattern for switching user contexts in later tests. No issues here.

Also applies to: 21-25, 39-45


288-388: Update‑after‑acceptance flow looks correct and realistic

The flow of:

  1. Removing the user from the organization,
  2. Inviting via SCIM,
  3. Accepting the invitation as the invited user (via authenticator.changeUserWithSuppliedContext + acceptOrDeclineInvitation),
  4. Switching back to the original admin context and updating the SCIM user,

matches the controller behavior and validates that updates work only after the invitation is accepted. Assertions on the final GET (name and active flag) look consistent with the SCIM controller implementation.


390-473: Patch‑after‑acceptance test is aligned with PATCH handler semantics

This test correctly:

  • Invites the existing user,
  • Has the invited user accept the organization invitation,
  • Patches active: false using a SCIM PatchOp body,
  • Verifies via a subsequent GET that active is false.

This mirrors the PATCH /Users/:userID handler’s replace/value.active branch and provides good coverage for the new activation/deactivation path.

controlplane/src/core/controllers/scim.ts (4)

294-303: Using organization‑level active in GET /Users/:userID is consistent with SCIM flows

Switching to active: user.active here aligns this endpoint with your new activation/deactivation logic (PUT/PATCH). As long as the list endpoint is updated similarly, this will give SCIM clients a consistent view of per‑organization membership activity.


317-371: Good use of UserInviteService and audit logging in user creation

The create handler:

  • Wraps the invitation flow in a DB transaction,
  • Delegates user provisioning/invitation to UserInviteService,
  • Records an audit log entry (scim.organization_invitation_created) with relevant actor and API key metadata,
  • Returns a SCIM-compliant User representation including the newly created id.

This is a clean separation of concerns and a solid improvement over controller-level orchestration.


399-489: PUT /Users/:userID update path matches SCIM expectations and adds useful audit logs

The PUT handler:

  • Validates org membership and the backing Keycloak user,
  • Updates Keycloak user details (name + groups),
  • Writes an audit log for the update,
  • Conditionally updates the org member’s active flag and logs activation/deactivation separately,
  • Returns an updated SCIM User representation.

This matches the SCIM full‑replace semantics and cleanly ties into your new active field and audit infrastructure.


491-569: PATCH handler correctly supports active replace operations, but only at org level

The PATCH handler:

  • Validates that the user is an org member,
  • Ignores non‑replace operations,
  • Handles both path: 'active' with string value and object { value: { active: boolean } } forms,
  • Updates setOrganizationMemberActive and batches corresponding audit logs.

Two points to be aware of:

  • It only toggles the organization member’s active flag; Keycloak’s enabled state is left unchanged. If that’s intentional (SCIM active == org membership status, not IdP status), this is fine but should be documented.
  • Non‑replace operations are silently ignored, which is permissible but may surprise clients expecting errors for unsupported ops.

Functionally, this is a reasonable and focused implementation of SCIM PATCH for activation/deactivation.

Comment thread controlplane/src/core/services/UserInviteService.ts Outdated
Comment thread controlplane/test/scim.test.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (2)
controlplane/test/scim.test.ts (2)

211-288: Fix duplicate invitation test to use email-filtered assertions

Line 282 has the same fragile totalCount === 1 assertion. To verify that no duplicate invitation was created for the same email, filter the invitations by email:

 const pendingOrgMembers = await client.getPendingOrganizationMembers({});
 expect(pendingOrgMembers.response?.code).toBe(EnumStatusCode.OK);
-expect(pendingOrgMembers.totalCount).toBe(1);

 const emails = pendingOrgMembers.pendingInvitations.map((inv) => inv.email);
-const exists = emails.includes(email);
-
-expect(exists).toBe(true);
+const invitationsForEmail = emails.filter(e => e === email);
+expect(invitationsForEmail).toHaveLength(1);

This verifies that exactly one invitation exists for the test email, regardless of other pending invitations in the organization.


163-209: Fix pending invitation assertions to be email-filtered instead of relying on totalCount

Line 203 asserts totalCount === 1, which is fragile because tests share organization state without inter-test cleanup. If prior tests create pending invitations (e.g., the skipped test at line 116), this assertion will fail.

You already filter by email at lines 205-208 to verify the specific invitation exists. Replace the totalCount assertion with a filtered check:

 const pendingOrgMembers = await client.getPendingOrganizationMembers({});
 expect(pendingOrgMembers.response?.code).toBe(EnumStatusCode.OK);
-expect(pendingOrgMembers.totalCount).toBe(1);

 const emails = pendingOrgMembers.pendingInvitations.map((inv) => inv.email);
-const exists = emails.includes(email);
-
-expect(exists).toBe(true);
+expect(emails).toContain(email);

This ensures the test verifies only its own invitation state.

🧹 Nitpick comments (2)
controlplane/src/core/services/UserInviteService.ts (2)

49-58: Advisory lock is enforced, but improve error message

The advisory lock is properly enforced by throwing an error when acquired is false. However, the error message "Slow down" is not user-friendly or descriptive.

Consider improving the error message:

 if (!advisoryLockRows?.[0]?.acquired) {
-  // Another request already acquired the lock for this invitation
-  throw new PublicError(EnumStatusCode.ERR, 'Slow down');
+  throw new PublicError(
+    EnumStatusCode.ERR,
+    'An invitation for this user is already being processed. Please try again in a moment.'
+  );
 }

Additionally, consider adding a database UNIQUE constraint on organization_invitations(organization_id, user_id) as a safety net against concurrent races if the advisory lock fails.


224-224: Consider injecting WEB_BASE_URL as configuration

The service directly accesses process.env.WEB_BASE_URL at lines 224 and 238. Consider passing this as a configuration parameter in the constructor to improve testability and reduce coupling to environment variables:

constructor(input: {
  db: PostgresJsDatabase<typeof schema>;
  logger: FastifyBaseLogger;
  keycloakRealm: string | undefined;
  keycloak: Keycloak;
  mailer: Mailer | undefined;
  webBaseUrl: string; // Add this
}) {
  // ...
  this.webBaseUrl = input.webBaseUrl;
}

Then use this.webBaseUrl instead of process.env.WEB_BASE_URL.

Also applies to: 238-238

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a6e6369 and 142e5aa.

📒 Files selected for processing (2)
  • controlplane/src/core/services/UserInviteService.ts (1 hunks)
  • controlplane/test/scim.test.ts (9 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-22T11:13:45.617Z
Learnt from: SkArchon
Repo: wundergraph/cosmo PR: 2224
File: router/pkg/authentication/keyfunc/keyfunc_test.go:142-154
Timestamp: 2025-09-22T11:13:45.617Z
Learning: When reviewing forked code, especially test files with test fixtures like JWT tokens, avoid suggesting modifications to maintain alignment with upstream and preserve the original author's structure. Test fixtures that are clearly marked as such (not real secrets) should generally be left unchanged in forked implementations.

Applied to files:

  • controlplane/test/scim.test.ts
📚 Learning: 2025-08-29T10:28:04.846Z
Learnt from: JivusAyrus
Repo: wundergraph/cosmo PR: 2156
File: controlplane/src/core/repositories/SubgraphRepository.ts:1749-1751
Timestamp: 2025-08-29T10:28:04.846Z
Learning: In the controlplane codebase, authentication and authorization checks (including organization scoping) are handled at the service layer in files like unlinkSubgraph.ts before calling repository methods. Repository methods like unlinkSubgraph() in SubgraphRepository.ts can focus purely on data operations without redundant security checks.

Applied to files:

  • controlplane/src/core/services/UserInviteService.ts
🧬 Code graph analysis (2)
controlplane/test/scim.test.ts (1)
controlplane/src/core/test-util.ts (1)
  • TestAuthenticator (223-226)
controlplane/src/core/services/UserInviteService.ts (6)
controlplane/src/core/services/Keycloak.ts (1)
  • Keycloak (8-442)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/repositories/OrganizationRepository.ts (3)
  • OrganizationRepository (50-1670)
  • memberCount (341-358)
  • memberships (259-339)
controlplane/src/core/repositories/UserRepository.ts (1)
  • UserRepository (18-178)
controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)
  • OrganizationInvitationRepository (12-276)
controlplane/src/core/repositories/OrganizationGroupRepository.ts (1)
  • OrganizationGroupRepository (9-356)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (go)
🔇 Additional comments (11)
controlplane/test/scim.test.ts (4)

7-7: LGTM: TestAuthenticator wiring

The TestAuthenticator import and setup properly enable multi-user test flows for invitation acceptance scenarios.

Also applies to: 21-21, 39-39


116-161: Verify that skipped test doesn't pollute subsequent test state

The skipped test would create a pending invitation if it ran. While it's appropriately skipped due to the studio client limitation, ensure that if this test is re-enabled in the future, it includes proper cleanup to avoid affecting subsequent assertions on totalCount.


290-390: LGTM: Invitation acceptance and update flow

The test properly orchestrates the multi-user flow by switching authenticator contexts to accept the invitation before updating the user. The assertions correctly verify the update operations.


392-475: LGTM: Invitation acceptance and patch flow

Similar to the PUT test, this correctly verifies the PATCH operation after invitation acceptance using proper authenticator context switching.

controlplane/src/core/services/UserInviteService.ts (7)

15-34: LGTM: Clean dependency injection

The constructor follows a clear dependency injection pattern with properly typed dependencies.


60-78: LGTM: Organization validation and limit enforcement

The organization lookup, member count checking, and feature limit enforcement are well-structured and provide clear error messages.


80-88: LGTM: Existing member check

The check prevents attempting to invite users who are already organization members.


90-128: LGTM: Keycloak-DB synchronization with defensive checks

The flow properly synchronizes user state between Keycloak and the local database. Lines 120-127 are defensive checks that ensure the user exists after all creation logic, which adds safety at minimal cost.


130-149: LGTM: Pending invitation handling

The logic correctly handles existing pending invitations by resending rather than creating duplicates.


151-172: LGTM: Group validation

The group validation ensures that all provided groups belong to the organization and throws a clear error if invalid groups are provided.


174-196: LGTM: Invitation creation and sending

The flow cleanly separates invitation creation (DB operation) from notification sending (email/Keycloak).

Comment thread controlplane/src/core/services/UserInviteService.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
controlplane/test/scim.test.ts (1)

154-162: Remove fragile totalCount assertion; email-based check at lines 158-161 is sufficient.

Line 156 asserts totalCount === 1, which assumes this is the only pending invitation in the organization. This is fragile because:

  • Test execution order can vary
  • Other tests in the same organization can leave pending invitations
  • The random email created at line 117 is never cleaned up, affecting subsequent tests if they check totalCount

Lines 158-161 already correctly verify that the specific email exists in pendingInvitations without relying on the total count. This is the proper approach for test isolation.

Apply this diff:

 const pendingOrgMembers = await client.getPendingOrganizationMembers({});
 expect(pendingOrgMembers.response?.code).toBe(EnumStatusCode.OK);
-expect(pendingOrgMembers.totalCount).toBe(1);
 
 const emails = pendingOrgMembers.pendingInvitations.map((inv) => inv.email);
 const exists = emails.includes(email);
 
 expect(exists).toBe(true);
🧹 Nitpick comments (2)
controlplane/test/scim.test.ts (2)

118-119: Consider using mockResolvedValue for async Keycloak methods.

spy.mockImplementation(vi.fn()) works but is unconventional. If executeActionsEmail is async (returns a Promise), use mockResolvedValue for clarity:

-const spy = vi.spyOn(keycloakClient, 'executeActionsEmail');
-spy.mockImplementation(vi.fn());
+const spy = vi.spyOn(keycloakClient, 'executeActionsEmail').mockResolvedValue(undefined);

This makes the intent explicit and avoids potential issues if the method expects a Promise return.


290-295: Strengthen assertion to verify no duplicate invitations.

The test title states "does not create multiple invitations," but lines 293-295 only check that the email exists without verifying there's exactly one invitation for that email.

Apply this diff to explicitly verify idempotency:

 const pendingOrgMembers = await client.getPendingOrganizationMembers({});
 expect(pendingOrgMembers.response?.code).toBe(EnumStatusCode.OK);
 
-const emails = pendingOrgMembers.pendingInvitations.map((inv) => inv.email);
-const exists = emails.includes(email);
-expect(exists).toBe(true);
+const invitationsForEmail = pendingOrgMembers.pendingInvitations.filter(inv => inv.email === email);
+expect(invitationsForEmail.length).toBe(1);

This directly asserts that only one invitation exists for the email, clearly demonstrating idempotency.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 04fe62f and e7f71e5.

📒 Files selected for processing (1)
  • controlplane/test/scim.test.ts (14 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-22T11:13:45.617Z
Learnt from: SkArchon
Repo: wundergraph/cosmo PR: 2224
File: router/pkg/authentication/keyfunc/keyfunc_test.go:142-154
Timestamp: 2025-09-22T11:13:45.617Z
Learning: When reviewing forked code, especially test files with test fixtures like JWT tokens, avoid suggesting modifications to maintain alignment with upstream and preserve the original author's structure. Test fixtures that are clearly marked as such (not real secrets) should generally be left unchanged in forked implementations.

Applied to files:

  • controlplane/test/scim.test.ts
📚 Learning: 2025-08-29T10:28:04.846Z
Learnt from: JivusAyrus
Repo: wundergraph/cosmo PR: 2156
File: controlplane/src/core/repositories/SubgraphRepository.ts:1749-1751
Timestamp: 2025-08-29T10:28:04.846Z
Learning: In the controlplane codebase, authentication and authorization checks (including organization scoping) are handled at the service layer in files like unlinkSubgraph.ts before calling repository methods. Repository methods like unlinkSubgraph() in SubgraphRepository.ts can focus purely on data operations without redundant security checks.

Applied to files:

  • controlplane/test/scim.test.ts
🧬 Code graph analysis (1)
controlplane/test/scim.test.ts (1)
controlplane/src/core/test-util.ts (1)
  • TestAuthenticator (223-226)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (go)
  • GitHub Check: build_test
  • GitHub Check: build_push_image
🔇 Additional comments (3)
controlplane/test/scim.test.ts (3)

2-2: LGTM! Clean test infrastructure additions.

The addition of vi for spying and TestAuthenticator for user context switching appropriately supports the new invitation flow tests. The setup properly wires the authenticator from test configuration.

Also applies to: 7-7, 21-21, 39-39


167-215: LGTM! Proper email-based assertion for test isolation.

This test correctly verifies the invitation exists by filtering pendingInvitations by email (lines 212-214) rather than checking totalCount. The cleanup-at-start pattern (lines 171-175) with user context switching via authenticator.changeUserWithSuppliedContext properly ensures a clean slate for the invitation flow test.


298-402: LGTM! Comprehensive invitation acceptance workflow coverage.

Both tests properly verify the complete invitation-based membership flow:

  1. Clean up prior state by rejecting invitations and removing memberships
  2. Create invitation via SCIM
  3. Accept invitation from the invited user's context
  4. Perform updates/patches once membership is established

The user context switching via authenticator.changeUserWithSuppliedContext correctly simulates multi-user workflows, and the assertions verify the expected state at each step.

Also applies to: 404-491

Comment thread controlplane/src/core/services/UserInviteService.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
controlplane/src/core/controllers/scim.ts (1)

169-176: active flag semantics are inconsistent between list and get

You’ve moved GET /Users/:userID to use user.active (organization membership) as the SCIM active flag, and PATCH /Users/:userID / PUT /Users/:userID now update setOrganizationMemberActive, which is good.

However, in GET /Users the active field is still derived from keycloakUsers[0].enabled ?? true for each member, so:

  • GET /Users may return active: true for a member whose org membership has been deactivated via SCIM.
  • GET /Users/:userID will simultaneously return active: false for the same user.

If active is intended to represent membership activity (as the update/patch paths suggest), consider switching list responses to use the same membership active flag (assuming getMembers exposes it) to keep SCIM views consistent.

Also applies to: 210-219, 294-303, 453-473

🧹 Nitpick comments (1)
controlplane/src/core/services/UserInviteService.ts (1)

203-251: Invitation sending and throttling behavior is reasonable, consider logging when mailer is missing

The membership‑based branch (Keycloak action email for first‑time users vs. custom mail for users with memberships) and 30‑minute resend throttle are coherent. When mailer is undefined, the method returns early and never updates lastSentAt; this is acceptable but might be opaque operationally.

Consider adding a debug/warn log before the early return so operators can see why invitations are not being emailed when a mailer is not configured.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e7f71e5 and ac73f0a.

📒 Files selected for processing (3)
  • controlplane/src/core/bufservices/user/inviteUser.ts (2 hunks)
  • controlplane/src/core/controllers/scim.ts (8 hunks)
  • controlplane/src/core/services/UserInviteService.ts (1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-29T10:28:04.846Z
Learnt from: JivusAyrus
Repo: wundergraph/cosmo PR: 2156
File: controlplane/src/core/repositories/SubgraphRepository.ts:1749-1751
Timestamp: 2025-08-29T10:28:04.846Z
Learning: In the controlplane codebase, authentication and authorization checks (including organization scoping) are handled at the service layer in files like unlinkSubgraph.ts before calling repository methods. Repository methods like unlinkSubgraph() in SubgraphRepository.ts can focus purely on data operations without redundant security checks.

Applied to files:

  • controlplane/src/core/services/UserInviteService.ts
🧬 Code graph analysis (3)
controlplane/src/core/services/UserInviteService.ts (8)
controlplane/src/core/services/Keycloak.ts (1)
  • Keycloak (8-442)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/repositories/OrganizationRepository.ts (3)
  • OrganizationRepository (50-1670)
  • memberCount (341-358)
  • memberships (259-339)
controlplane/src/core/repositories/UserRepository.ts (1)
  • UserRepository (18-178)
controlplane/src/core/repositories/OrganizationInvitationRepository.ts (1)
  • OrganizationInvitationRepository (12-276)
controlplane/src/core/errors/errors.ts (1)
  • PublicError (17-17)
controlplane/src/types/index.ts (2)
  • UserDTO (284-287)
  • OrganizationInvitationDTO (313-319)
controlplane/src/core/repositories/OrganizationGroupRepository.ts (1)
  • OrganizationGroupRepository (9-356)
controlplane/src/core/bufservices/user/inviteUser.ts (1)
controlplane/src/core/services/UserInviteService.ts (1)
  • UserInviteService (15-252)
controlplane/src/core/controllers/scim.ts (5)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/services/UserInviteService.ts (1)
  • UserInviteService (15-252)
controlplane/src/core/repositories/AuditLogRepository.ts (2)
  • AuditLogRepository (28-137)
  • AddAuditLogInput (6-23)
controlplane/src/core/errors/errors.ts (1)
  • isPublicError (33-35)
controlplane/src/db/schema.ts (1)
  • auditLogs (1926-1962)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (5)
  • GitHub Check: build_test
  • GitHub Check: build_push_image
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: Analyze (go)
🔇 Additional comments (5)
controlplane/src/core/bufservices/user/inviteUser.ts (1)

27-40: Delegation to UserInviteService looks correct

Service construction and usage (including awaiting service.inviteUser and passing groups/email) plus sourcing organizationSlug from authContext are consistent with the new service-based flow and audit model. No issues from this wrapper.

Also applies to: 42-45

controlplane/src/core/services/UserInviteService.ts (1)

94-132: User + Keycloak existence flow is solid

The logic to (a) ensure the Keycloak user exists, (b) mirror the user into the local users table, and (c) re‑fetch to guarantee presence before proceeding is sound and transactionally safe with the current repository patterns.

Based on learnings

controlplane/src/core/controllers/scim.ts (3)

27-80: SCIM request typing and content-type parser are appropriate

Typed FastifyRequest aliases for list/get/create/update/patch and the dedicated application/scim+json parser (parsing as string then JSON.parse) match SCIM usage and keep handlers nicely typed. No issues here.


396-487: User update flow (PUT /Users/:userID) is coherent with new audit model

The update route correctly:

  • Ensures the user belongs to the organization and exists in Keycloak.
  • Updates basic user details and groups in Keycloak.
  • Writes an audit log for the general update, and a second audit entry when active changes, tied to membership via setOrganizationMemberActive.

The structure and audit logging match the expected patterns in this codebase.

Based on learnings


489-567: SCIM PATCH /Users/:userID logic is clear and well-scoped

The patch handler:

  • Validates the organization member exists.
  • Restricts behavior to replace operations on active (either as path: "active" with string value or in a value object).
  • Updates membership via setOrganizationMemberActive and batches audit log entries with a shared partial payload.

This is a good, minimal SCIM PATCH implementation focused on activation/deactivation semantics.

Comment thread controlplane/src/core/controllers/scim.ts
Comment thread controlplane/src/core/controllers/scim.ts
Comment thread controlplane/src/core/services/UserInviteService.ts
Copy link
Copy Markdown
Contributor

@StarpTech StarpTech left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
controlplane/src/core/controllers/scim.ts (1)

162-260: Inconsistent active status source between list and get endpoints.

The GET /Users (list) endpoint reads the active status from Keycloak's enabled field (lines 214, 244), while GET /Users/:userID returns the database user.active field (line 302). This inconsistency could cause the same user to show different active states in list vs. detail views.

Consider using a consistent source—either always read from the database user.active field (recommended, as it's what PUT/PATCH update), or always from Keycloak's enabled field.

Apply this diff to make the list endpoint consistent with the detail endpoint:

       users.push({
         id: user.userID,
         emails: [{ value: user.email }],
         userName: user.email,
-        active: keycloakUsers[0].enabled ?? true,
+        active: user.active,
         name: {
           givenName: keycloakUsers[0].firstName || '',
           familyName: keycloakUsers[0].lastName || '',

and apply the same change around line 244:

         users.push({
           id: member.userID,
           emails: [{ value: member.email }],
           userName: member.email,
-          active: keycloakUsers[0].enabled ?? true,
+          active: member.active,
           name: {
             givenName: keycloakUsers[0].firstName || '',
             familyName: keycloakUsers[0].lastName || '',
♻️ Duplicate comments (1)
controlplane/src/core/controllers/scim.ts (1)

370-386: Refine error status mapping for better SCIM compliance.

The error handling still maps most PublicError cases to HTTP 400 with SCIM status 500, while only ERR_ALREADY_EXISTS gets status 409. This loses useful semantics for SCIM clients and creates a mismatch between the HTTP code (400) and SCIM status (500).

Consider mapping EnumStatusCode values more explicitly:

  • ERR_ALREADY_EXISTS → HTTP 409 / SCIM status: 409
  • ERR_LIMIT_REACHED → HTTP 429 / SCIM status: 429
  • ERR_NOT_FOUND → HTTP 404 / SCIM status: 404
  • ERR (throttle "Slow down") → HTTP 429 / SCIM status: 429
  • Fallback → HTTP 500 / SCIM status: 500

Ensure the outer HTTP status always matches the SCIM status field.

Apply this diff to improve error mapping:

     } catch (err: unknown) {
       if (isPublicError(err)) {
+        let httpStatus = 500;
+        let scimStatus = 500;
+        
+        if (err.code === EnumStatusCode.ERR_ALREADY_EXISTS) {
+          httpStatus = 409;
+          scimStatus = 409;
+        } else if (err.code === EnumStatusCode.ERR_LIMIT_REACHED) {
+          httpStatus = 429;
+          scimStatus = 429;
+        } else if (err.code === EnumStatusCode.ERR_NOT_FOUND) {
+          httpStatus = 404;
+          scimStatus = 404;
+        } else if (err.message === 'Slow down') {
+          httpStatus = 429;
+          scimStatus = 429;
+        }
+        
-        return res.code(400).send(
+        return res.code(httpStatus).send(
           ScimError({
             detail: err.message,
-            status: err.code === EnumStatusCode.ERR_ALREADY_EXISTS ? 409 : 500,
+            status: scimStatus,
           }),
         );
🧹 Nitpick comments (1)
controlplane/src/core/controllers/scim.ts (1)

530-544: Consider more robust string-to-boolean conversion.

Line 532 uses operation.value?.toLowerCase() === 'true' to parse the active flag. This only recognizes the literal string "true" (case-insensitive). While this may match the SCIM spec, it's brittle if clients send variations like "1", "yes", or if the value is accidentally sent as a boolean that gets stringified.

Consider a more defensive approach:

       if ('path' in operation) {
         if (operation.path.toLowerCase() === 'active') {
-          const active = operation.value?.toLowerCase() === 'true';
+          const active = operation.value?.toLowerCase() === 'true' || operation.value === 'True';
           await opts.organizationRepository.setOrganizationMemberActive({
             id: orgMember.orgMemberID,
             organizationId: authContext.organizationId,
             active,
           });

Alternatively, if you want to strictly enforce SCIM compliance, consider adding validation that rejects non-compliant values with a descriptive error.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ac73f0a and 80802a0.

📒 Files selected for processing (1)
  • controlplane/src/core/controllers/scim.ts (8 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
controlplane/src/core/controllers/scim.ts (5)
controlplane/src/core/services/Mailer.ts (1)
  • Mailer (13-101)
controlplane/src/core/services/UserInviteService.ts (1)
  • UserInviteService (15-252)
controlplane/src/core/repositories/AuditLogRepository.ts (2)
  • AuditLogRepository (28-137)
  • AddAuditLogInput (6-23)
controlplane/src/core/errors/errors.ts (1)
  • isPublicError (33-35)
controlplane/src/db/schema.ts (1)
  • auditLogs (1926-1962)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (6)
  • GitHub Check: build_push_image
  • GitHub Check: build_test
  • GitHub Check: build_test
  • GitHub Check: Analyze (javascript-typescript)
  • GitHub Check: Analyze (go)
  • GitHub Check: build_push_image
🔇 Additional comments (4)
controlplane/src/core/controllers/scim.ts (4)

1-80: LGTM!

The new imports and type definitions are well-structured. The optional mailer parameter in ScimControllerOptions correctly aligns with the UserInviteService design, and the internal request types improve type safety throughout the endpoints.


98-105: LGTM!

The updated content type parser signature correctly ignores the unused request parameter. The parser only needs the body to parse JSON, so this is a valid simplification.


397-487: LGTM!

The PUT endpoint implementation is well-structured:

  • Updates Keycloak user details appropriately
  • Comprehensive audit logging for both general updates and active status changes
  • Conditional check (line 454) prevents redundant database updates when active status hasn't changed
  • Returns proper SCIM-formatted response

561-564: LGTM!

The batch audit logging pattern is efficient and correct. Collecting audit log entries and writing them in a single call reduces database round trips, and the conditional check prevents unnecessary operations.

@wilsonrivera wilsonrivera merged commit cafd1fc into main Nov 22, 2025
12 checks passed
@wilsonrivera wilsonrivera deleted the wilson/eng-8506-wun-q425-01 branch November 22, 2025 16:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants