Skip to content

feat(org): treat platform admins as members in internal organizations#3378

Merged
tofikwest merged 9 commits into
mainfrom
tofik/internal-org-participation
Jul 10, 2026
Merged

feat(org): treat platform admins as members in internal organizations#3378
tofikwest merged 9 commits into
mainfrom
tofik/internal-org-participation

Conversation

@tofikwest

@tofikwest tofikwest commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

What

Adds an Organization.isInternal flag so platform-operated organizations (e.g. our own) can run compliance with their platform-admin staff as real members — assignable, counted toward compliance progress, and included in notifications, reminders, and device rollups.

For every other organization the behavior is unchanged, with one deliberate exception: a global platform admin who also holds an owner member role in a non-internal org is now excluded from notifications/mentions/reminders, whereas some of those paths previously kept them. This normalizes notifications onto the same rule the assignment/approval guards already used (which never had an owner carve-out), so the participation rule is consistent everywhere. The flag defaults to false, and it's an admin-only toggle on the org admin screen.

Why

Platform admins (global staff accounts) are intentionally excluded from an organization's day-to-day membership everywhere in the product. That's correct for the orgs they only enter to support. It's wrong for a platform-operated org, where those same accounts are the real members and need to run compliance like anyone else.

How

  • One shared predicate, isOrgParticipant(userRole, { orgIsInternal }), in @trycompai/auth (new @trycompai/auth/participation subpath so it stays dependency-light; mirrored in a dependency-free app helper for the Trigger.dev bundle).
  • Replaces the scattered inline user.role === 'admin' participation checks across assignment/approval, compliance counting, notifications/reminders, and device queries with that single rule.
  • Platform-admin access/permission guards are intentionally untouched — this only changes membership/participation, never privileges.
  • Admin toggle: isInternal added to the admin org update endpoint + a "Platform settings → Internal organization" switch on the org detail screen.

Data

  • Migration 20260708120000_add_organization_is_internal adds isInternal BOOLEAN NOT NULL DEFAULT false.

Tests

  • New unit tests for the predicate (jest + vitest).
  • Existing task / policy / SOA / task-management / notifier suites pass unchanged, confirming behavior is preserved for orgs with the flag off.

Rollout

Flag is off everywhere on merge. Enable per-org from the admin screen (or PATCH /v1/admin/organizations/:id { isInternal: true }) only for platform-operated orgs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb


Summary by cubic

Treat platform admins as real members in internal (platform‑operated) orgs via Organization.isInternal, unifying participation behind isOrgParticipant across API, app, and Trigger.dev. Also hardens assignment to reject non‑member IDs and fixes mention notifications to only reach the mentioned users.

  • New Features

    • Admin toggle: “Internal organization” switch with confirmation and live refresh; isInternal added to the admin org update DTO and endpoint (explicit null now 400s).
    • Shared rule: isOrgParticipant in @trycompai/auth/participation (mirrored in the app for Trigger.dev) with helpers isMemberOrgParticipant, orgParticipantMemberWhere, and orgParticipantMemberWhereForFlag that return a spread‑safe AND fragment. Replaces inline checks across policies/tasks/risks/vendors/SOA, notifications/mentions, device APIs, scheduled tasks, browser automation, and the assignee picker.
    • Safety/perf: Assignee guards now reject non‑members across tasks (single/bulk), task items (create/update), policies, vendors, and risks; mention notifiers intersect (not overwrite) user filters; vendor mitigation only auto‑assigns when the author exists, is active, and is a participant; task notifier consolidates org lookup; compliance filters skip the org‑internal lookup when no platform admins are present.
  • Migration

    • DB: adds Organization.isInternal BOOLEAN NOT NULL DEFAULT false (migration 20260708120000_add_organization_is_internal).
    • Rollout: flag is off by default; enable per org via the admin UI or PATCH /v1/admin/organizations/:id { isInternal: true }.

Written for commit dd071b6. Summary will update on new commits.

Review in cubic

Adds `Organization.isInternal` so platform-operated organizations can run
compliance with their platform-admin staff as real participants — assignable,
counted toward compliance progress, and included in notifications and device
rollups. Behavior is unchanged for every other organization (flag defaults to
false; admin-only toggle on the org admin screen).

Centralizes the rule behind a single `isOrgParticipant` predicate
(`@trycompai/auth/participation`, mirrored for the Trigger.dev bundle),
replacing the scattered inline `user.role === 'admin'` participation checks
across assignment/approval, compliance counting, notifications, reminders, and
device queries. Platform-admin access guards are intentionally untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
comp-framework-editor Ready Ready Preview, Comment Jul 10, 2026 5:32pm
2 Skipped Deployments
Project Deployment Actions Updated (UTC)
app Skipped Skipped Jul 10, 2026 5:32pm
portal Skipped Skipped Jul 10, 2026 5:32pm

Request Review

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 41 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/api/src/trigger/browser-automation/run-browser-automation.ts Outdated
Comment thread apps/app/src/lib/org-participation.ts Outdated
Comment thread apps/api/src/admin-organizations/dto/update-admin-organization.dto.ts Outdated
…idation, keep dep boundary

- run-browser-automation: use the shared `orgParticipantMemberWhere` helper
  instead of an inline copy, so it matches the other task notifiers (uses the
  role constant + owner exception) and still includes everyone in internal orgs.
- update-admin-organization DTO: reject an explicit `null` isInternal at
  validation (400) instead of letting it reach the non-null column (500).
- org-participation (app): stop re-exporting `isOrgParticipant` from the module
  that imports `@db/server`; consumers import it from the dependency-free
  `org-participation-rule` so server-only code can't leak into client/Trigger bundles.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb
@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 41 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts Outdated
Comment thread apps/api/src/utils/org-participation.ts Outdated
Comment thread apps/app/src/lib/org-participation-rule.ts
Comment thread apps/app/src/components/SelectAssignee.tsx Outdated
Comment thread apps/app/src/lib/org-participation.ts
…rule, guard boundaries

- generate-vendor-mitigation: fail closed — only assign the author when the
  member actually exists in this org (add `author &&` guard) so a missed lookup
  can never write an unknown/cross-org member id.
- SelectAssignee: use the shared `isOrgParticipant` predicate instead of an
  inline copy, so the picker can't drift from the backend rule.
- lib/org-participation.ts: add `import 'server-only'` (matches compliance.ts /
  permissions.server.ts) so this DB-backed helper can't be pulled into a client bundle.
- Add a drift guard test asserting the app-local participation rule stays
  identical to `@trycompai/auth/participation`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb
@vercel vercel Bot temporarily deployed to Preview – portal July 9, 2026 00:55 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

Reviewed the latest cubic pass (5 issues). Verdict + resolution below — fixes in ef1a53fe5.

Fixed (valid):

  • P1 · generate-vendor-mitigation.ts — assign author without existence guard. Not a regression (the original author?.user.role === 'admin' ? null : authorId had the same behavior when the lookup missed), but a real latent bug. Hardened to fail closed: author && isOrgParticipant(...), so a missing/cross-org member id can never be written.
  • P2 · SelectAssignee.tsx — inline rule. Now calls the shared isOrgParticipant predicate instead of an inline copy, so the picker can't drift from the backend.
  • P2 · org-participation.ts — missing server-only. Added import 'server-only' to match compliance.ts / permissions.server.ts.
  • P2 · duplicated rule (org-participation-rule.ts). The app-local mirror is intentional (the auth index pulls better-auth, which the Trigger.dev bundler can't handle). Added a drift-guard test asserting the mirror stays byte-for-byte equivalent to @trycompai/auth/participation across every role × internal-flag combination, so CI fails if they ever diverge.

Not a real / new issue — marking as won't-fix:

  • P1 · orgParticipantMemberWhere null-role handling. The Prisma fragment ({ user: { role: { not: 'admin' } } }) is identical to main — this null behavior is pre-existing, not introduced here, and the same in-memory-vs-query split existed before this PR. In practice it's moot: User.role is String? @default("user") and better-auth sets defaultRole: 'user', so members effectively never have a null role. I'm intentionally not changing the query fragment, because doing so would alter notification/device recipient behavior on customer orgs for a case that doesn't occur. Happy to align it in a separate change if we ever allow null roles.

All source typechecks clean (only the pre-existing CompanySubmissionWizard errors remain) and the participation tests pass.

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 41 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread apps/api/src/utils/org-participation.ts
Comment thread apps/app/src/lib/compliance.ts Outdated
Comment thread apps/app/src/trigger/tasks/onboarding/generate-vendor-mitigation.ts Outdated
…r, skip needless query

- orgParticipantMemberWhere: make it a faithful Prisma translation of
  isOrgParticipant — for non-internal orgs exclude only platform admins
  (include null roles; drop the owner carve-out, which the isInternal flag now
  supersedes). Removes the split source of truth between the predicate and the
  query fragment.
- generate-vendor-mitigation: add `deactivated: false` to the author lookup so a
  deactivated member can't be auto-assigned (matches the other trigger tasks).
- filterComplianceMembers (app + api): skip the `getOrgIsInternal` query when the
  member list has no platform admins — the flag can't change the result there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWG1YQ8KsjefFAvRV7pdtb
@vercel vercel Bot temporarily deployed to Preview – portal July 9, 2026 02:03 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

Third cubic pass (3 issues) — all addressed in 77df89cdb.

  • P1 · orgParticipantMemberWhere diverged from isOrgParticipant. Valid consistency point (not a regression — both the owner carve-out in notifications and the strict rule in assignment came from main). Resolved by making the where-fragment a faithful Prisma translation of the predicate: for non-internal orgs it now excludes only platform admins, includes null roles explicitly (role: { not } skips NULL in SQL), and drops the owner carve-out — which the isInternal flag now supersedes. This also closes the earlier null-role item I'd marked won't-fix; they shared the same root. Net behavior delta is theoretical (platform-admin owners / null-role members in customer orgs effectively don't exist).
  • P2 · vendor author lookup missing deactivated: false. Real edge that the internal-org path newly exposed (admins are now assignable). Added deactivated: false to match the other trigger tasks, so a deactivated member falls through to null.
  • P2 · filterComplianceMembers always queried getOrgIsInternal. Valid perf note. Now short-circuits: the flag only matters when a platform admin is in the list, so the extra query is skipped in the common (no-admin) case. Applied to both the app and API filters.

Source typechecks clean (only the pre-existing CompanySubmissionWizard errors) and the participation + digest tests pass. The predicate and its Prisma translation are now a single consistent rule, guarded by the cross-package drift test added last round.

@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 41 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/utils/org-participation.ts Outdated
Comment thread apps/api/src/trigger/browser-automation/run-browser-automation.ts
Comment thread apps/app/src/trigger/tasks/task/weekly-task-reminder.ts
…ternal toggle

- org-participation: return the participant where-fragment wrapped in `AND`
  (via new `orgParticipantMemberWhereForFlag`) so spreading it no longer
  overwrites a caller's existing `user` filter. The two mention notifiers
  constrain `user: { id: { in } }`, which the bare `user` key clobbered, so
  notifications reached every non-admin member instead of the mentioned ones.
  Adds a spread-safety unit test.
- OrganizationDetail: refresh the server layout (router.refresh) after toggling
  isInternal for the current org so OrgInternalProvider isn't stale, and require
  a confirmation dialog before changing this org-wide setting.
- run-browser-automation: select `isInternal` in the existing org query and use
  the pure where-builder, dropping the redundant getOrgIsInternal query.
- Normalize participation onto isOrgParticipant everywhere: drop the per-member
  owner carve-out from weekly-task-reminder, task-schedule, policy-schedule and
  the task-item assignment notifier (matches the API where-fragment and the
  predicate); remove now-dead role selections and stale comments.
- tests: add a PointerEvent shim to the vitest setup (jsdom lacks it, base-ui
  Switch needs it), fix admin-org test mocks missing the new isInternal field,
  and cover the internal-toggle confirmation flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzEVwhhoFghng6SDMGecMq
@vercel vercel Bot temporarily deployed to Preview – portal July 9, 2026 15:27 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

Addressed the latest cubic pass (commit 26cecbe). Verdict + resolution below.

Fixed (valid):

  • P1 · orgParticipantMemberWhere overwrote a caller's user filter. The helper returned a bare { user: {...} }, so spreading it after the mention notifiers' user: { id: { in: mentionedUserIds } } clobbered that key — the query then matched every non-admin member instead of only the mentioned ones. The fragment is now wrapped in AND (via a new pure orgParticipantMemberWhereForFlag) so Prisma intersects it with any existing user condition. Added a spread-safety unit test that asserts the caller's user filter survives.
  • P1 · Stale isInternal after toggling the current org. OrganizationDetail now calls router.refresh() when org.id === currentOrgId, so the server-rendered OrgInternalProvider (and consumers like the assignee picker) pick up the new value without a full reload.
  • P2 · No confirmation on the internal toggle. The switch now opens an AlertDialog and only PATCHes after the admin confirms (both enable and disable), with copy warning it's for platform-operated orgs only.
  • P2 · Redundant query in run-browser-automation. It now selects isInternal in the existing org lookup and uses the pure orgParticipantMemberWhereForFlag, dropping the extra getOrgIsInternal round-trip.
  • P2 · Owner carve-out inconsistency. Cubic flagged weekly-task-reminder, but the same !isOwner carve-out was also in task-schedule, policy-schedule, and the task-item assignment notifier. Since the API where-fragment already dropped the owner carve-out (the isInternal flag supersedes it), I removed it from all four so every site is exactly isOrgParticipant. Also removed the now-dead role selections and stale comments.

Extra cleanup: fixed the admin-org test mocks that were missing the new required isInternal field (a typecheck break introduced when the field was added), added a PointerEvent shim to the vitest setup (jsdom lacks it, which base-ui's Switch needs), and added tests for the confirmation flow.

API participation/notifier/SOA/task-management suites pass (98); app participation + org-detail suites pass. Remaining typecheck failures are all pre-existing and in files this PR doesn't touch.

…rticipation

# Conflicts:
#	apps/app/src/app/api/people/agent-devices/route.ts
@tofikwest

Copy link
Copy Markdown
Contributor Author

@cubic-dev-ai review it

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@cubic-dev-ai review it

@tofikwest I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 44 files

Confidence score: 5/5

  • Safe to merge after the addressed issues were fixed.

You've manually re-run cubic several times on this PR. Each manual re-review checks the full PR again and counts toward your usage quota. To preserve your usage limits, we recommend letting cubic automatically review new commits.
Tip: instead of fixing issues one by one fix them all with cubic

Re-trigger cubic

Comment thread apps/api/src/policies/policies.service.ts Outdated
Comment thread apps/api/src/tasks/tasks.service.ts
Comment thread apps/api/src/vendors/vendors.service.ts Outdated
Comment thread apps/api/src/task-management/task-management.service.ts
Comment thread apps/api/src/comments/comment-mention-notifier.service.ts
Comment thread apps/app/src/trigger/tasks/task/policy-schedule.ts
Comment thread apps/api/src/tasks/task-notifier.service.ts Outdated
The assignee guards only threw when a member was found in the org and failed the
participant check. When the org-scoped `member.findFirst` returned null — the id
belongs to another org or doesn't exist — the guard was skipped and the id was
persisted, since assignee columns don't enforce same-org membership. This gap is
pre-existing (the prior `member?.user.role === 'admin'` check had the same null
hole); hardened here while the participation refactor already touches these
guards. Every assignee guard now rejects a non-member with a clear 400.

Covers tasks (bulk + single update), task items (create + update), policies
(create), vendors, and risks. Approver guards already rejected null members.

Also consolidate task-notifier's automation-failure paths, which fetched the org
twice (once for isInternal via orgParticipantMemberWhere, once for name), into a
single `{ name, isInternal }` lookup + orgParticipantMemberWhereForFlag.

Adds a regression test for the cross-org assignee rejection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XzEVwhhoFghng6SDMGecMq
@vercel vercel Bot temporarily deployed to Preview – portal July 10, 2026 17:31 Inactive
@vercel vercel Bot temporarily deployed to Preview – app July 10, 2026 17:31 Inactive
@tofikwest

Copy link
Copy Markdown
Contributor Author

Latest cubic pass (run 7dbbcf37) — verdicts + resolution. Fixes in dd071b65f.

Fixed (real issues):

  • P1 × 4 · cross-org assignee (tasks.service, task-management.service, policies.service, vendors.service): the assignee guard only threw when the org-scoped member.findFirst returned a member. A member id from another org — or a non-existent id — returned null, so the guard was skipped and the id was persisted (assignee columns don't enforce same-org membership). This gap is pre-existing (the prior member?.user.role === 'admin' check had the identical null hole), but I hardened it since the participation refactor already touches these lines. Every assignee guard now rejects a non-member with a clear 400. I applied the same fix to the sibling sites cubic didn't flag — tasks single-update, task-item update, and risks — so the whole class is consistent. Approver guards already rejected null members. Added a regression test.
  • P2 · redundant org query (task-notifier): the automation-failure paths fetched the org twice (once for isInternal via the helper, once for name); consolidated into one { name, isInternal } lookup + orgParticipantMemberWhereForFlag.

Intentional (confirming, not a bug):

  • P2 × 3 · owner carve-out removed (comment-mention, task-item-assignment, policy-schedule): correct observation. Platform-admin owners of a non-internal org no longer receive these notifications. This is deliberate — the assignment/approval guards never had the owner carve-out (they always excluded all platform admins), so this makes notifications consistent with assignment, and isInternal is now the single mechanism for "platform admins participate here." The only affected user is a global platform admin (Comp AI staff) who is also an owner of a customer org, which doesn't occur in practice. The "unchanged for non-internal orgs" wording in the description is slightly too strong for that edge case; I'll tighten it. Happy to restore the carve-out if strict parity is preferred.

Already addressed:

  • org-participation-rule.ts duplicate rule — covered by the cross-package drift-guard test added earlier.

Resolving these threads accordingly.

@tofikwest tofikwest merged commit b6033ef into main Jul 10, 2026
11 checks passed
@tofikwest tofikwest deleted the tofik/internal-org-participation branch July 10, 2026 18:04
@claudfuen

Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 3.101.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants