From 4031d542b0759cf03acb028fc8f22f418de0906a Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 4 Aug 2026 09:39:35 -0500 Subject: [PATCH 1/5] feat(organizations): add admin role with owner-equivalent authority Introduces an `admin` organization role that holds the same authority as `owner`. There is no action an owner can take that an admin cannot. - Add `admin` to the canonical role union (`packages/db` schema-types and `@kilocode/app-shared/organizations`). The `role` column is plain `text` with a TS-level `$type`, so no migration is required. - Add shared `ORGANIZATION_MANAGE_ROLES` (owner, admin) and `ORGANIZATION_BILLING_ROLES` (owner, admin, billing_manager) constants and replace the literal role arrays that previously duplicated them. - Rename `organizationOwnerProcedure`/`organizationOwnerMutationProcedure` to `organizationAdminProcedure`/`organizationAdminMutationProcedure` and widen them to owner+admin, so every previously owner-gated org route (members, settings, groups, modes, SSO, usage recommendations) now accepts admins. - Include `admin` in role priority and in parent-organization inheritance. - Widen the matching frontend gates via `canManageOrganization` so admins are not shown a read-only UI for routes the API now permits, and surface the role in the label maps, invite dialog, and role dropdown. Adds admin-parity coverage to the members router tests and points the role array assertions at the shared constant so they cannot drift. --- .../(app)/components/OrganizationSwitcher.tsx | 2 + .../[id]/distribute-funds/page.tsx | 3 +- .../integrations/linear/reinstall/route.ts | 3 +- .../[id]/subscriptions/kilo-pass/page.tsx | 3 +- .../subscriptions/kilo-pass/setup/page.tsx | 3 +- .../organizations/[id]/subscriptions/page.tsx | 3 +- .../[id]/subscriptions/seats/page.tsx | 3 +- .../organizations/[id]/welcome/layout.tsx | 3 +- .../OrganizationAdmin/AddMemberDialog.tsx | 1 + .../app/api/auto-routing/mode/route.test.ts | 3 +- .../src/app/api/auto-routing/mode/route.ts | 3 +- .../api/auto-routing/settings/route.test.ts | 3 +- .../app/api/auto-routing/settings/route.ts | 3 +- .../bitbucket/connect/route.test.ts | 3 +- .../integrations/linear/connect/route.test.ts | 3 +- apps/web/src/app/payments/topup/route.ts | 3 +- .../auto-routing/AutoRoutingModeCard.test.ts | 10 ++- .../components/organizations/BYOKContent.tsx | 5 +- .../organizations/FreeTrialWarningBanner.tsx | 7 +- .../OrganizationCodeIndexing.tsx | 3 +- .../organizations/OrganizationDashboard.tsx | 9 +- .../organizations/OrganizationMembersCard.tsx | 3 +- .../organizations/SSOSignupCard.tsx | 5 +- .../SidebarRoleTestingDropdown.tsx | 1 + .../custom-modes/CustomModesLayout.tsx | 3 +- .../groups/OrganizationGroupsPage.tsx | 5 +- .../members/InviteMemberDialog.tsx | 9 +- .../members/MemberRoleDropdown.tsx | 9 +- .../OrganizationProvidersAndModelsPage.tsx | 7 +- .../UsageAnalyticsDashboard.tsx | 3 +- .../oauth/platforms/bitbucket-callback.ts | 3 +- .../oauth/platforms/bitbucket-connect.ts | 3 +- apps/web/src/lib/integrations/oauth/routes.ts | 3 +- .../platforms/bitbucket/credentials.ts | 3 +- ...access-token-organization-authorization.ts | 3 +- .../web/src/lib/integrations/resolve-owner.ts | 3 +- .../src/lib/kilo-pass-org/notifications.ts | 3 +- apps/web/src/lib/kilo-pass-org/service.ts | 5 +- .../lib/organizations/organization-auth.ts | 10 ++- .../organization-shared-utils.tsx | 4 + apps/web/src/routers/byok-router.ts | 11 +-- .../code-review-analytics-router.ts | 3 +- .../code-reviews/review-memory-router.ts | 11 +-- apps/web/src/routers/gitlab-router.ts | 3 +- .../organization-admin-router.ts | 3 +- .../organization-groups-router.ts | 22 ++--- .../organization-members-router.test.ts | 84 +++++++++++++++++++ .../organization-members-router.ts | 32 ++++--- .../organization-modes-router.ts | 13 +-- .../organization-settings-router.ts | 14 ++-- .../organizations/organization-sso-router.ts | 11 +-- .../organization-usage-details-router.ts | 6 +- apps/web/src/routers/organizations/utils.ts | 31 ++++--- .../web/src/routers/usage-analytics-router.ts | 14 ++-- .../src/organizations/roles.test.ts | 24 ++++-- .../app-shared/src/organizations/roles.ts | 37 ++++++-- packages/db/src/schema-types.ts | 2 +- 57 files changed, 343 insertions(+), 142 deletions(-) diff --git a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx index f062a3bb74e..0fa0e634670 100644 --- a/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx +++ b/apps/web/src/app/(app)/components/OrganizationSwitcher.tsx @@ -97,6 +97,8 @@ export function OrganizationSwitcherView({ switch (role) { case 'owner': return 'Owner'; + case 'admin': + return 'Admin'; case 'billing_manager': return 'Billing Manager'; case 'member': diff --git a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx index bb3f3890458..d5a19854bf4 100644 --- a/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/distribute-funds/page.tsx @@ -1,4 +1,5 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { DistributeFundsPage } from './DistributeFundsPage'; export default async function OrganizationDistributeFundsPage({ @@ -9,7 +10,7 @@ export default async function OrganizationDistributeFundsPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts index 0227b57cc4f..48c380bbb48 100644 --- a/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts +++ b/apps/web/src/app/(app)/organizations/[id]/integrations/linear/reinstall/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; import { PLATFORM } from '@/lib/integrations/core/constants'; @@ -20,7 +21,7 @@ export async function GET(request: NextRequest, context: RouteContext) { } try { - await ensureOrganizationAccess({ user }, id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, id, ORGANIZATION_BILLING_ROLES); await requireActiveSubscriptionOrTrial(id); } catch { return NextResponse.redirect(new URL('/integrations?error=unauthorized', request.url)); diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx index a2ba940348b..322f498d08e 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/kilo-pass/page.tsx @@ -1,6 +1,7 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { OrgKiloPassBillingHistory } from '@/components/subscriptions/org-kilo-pass/OrgKiloPassBillingHistory'; import { OrgKiloPassDetail } from '@/components/subscriptions/org-kilo-pass/OrgKiloPassDetail'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationKiloPassPage({ params, @@ -10,7 +11,7 @@ export default async function OrganizationKiloPassPage({ return ( (
( )} diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx index d45cb8d34d5..7ff3e6d1cfb 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/page.tsx @@ -1,5 +1,6 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { OrgSubscriptions } from '@/components/subscriptions/OrgSubscriptions'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationSubscriptionsPage({ params, @@ -9,7 +10,7 @@ export default async function OrganizationSubscriptionsPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx b/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx index 214a52a0e65..2b803f7805c 100644 --- a/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/subscriptions/seats/page.tsx @@ -1,5 +1,6 @@ import { OrganizationByPageLayout } from '@/components/organizations/OrganizationByPageLayout'; import { SeatsDetail } from '@/components/subscriptions/seats/SeatsDetail'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; export default async function OrganizationSeatsSubscriptionPage({ params, @@ -9,7 +10,7 @@ export default async function OrganizationSeatsSubscriptionPage({ return ( } /> ); diff --git a/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx b/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx index af172f98a5c..aa9937dba4d 100644 --- a/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx +++ b/apps/web/src/app/(app)/organizations/[id]/welcome/layout.tsx @@ -1,6 +1,7 @@ import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; import { signInUrlWithCallbackPath } from '@/lib/user/server'; import { OrganizationContextProvider } from '@/components/organizations/OrganizationContext'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { redirect } from 'next/navigation'; export default async function WelcomeLayout({ @@ -12,7 +13,7 @@ export default async function WelcomeLayout({ }) { const { id } = await params; const organizationId = decodeURIComponent(id); - const result = await getAuthorizedOrgContext(organizationId, ['owner', 'billing_manager']); + const result = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES); if (!result.success) { const href = result.nextResponse.status === 401 ? await signInUrlWithCallbackPath() : '/profile'; diff --git a/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx b/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx index 37e0430cae0..f73cab16456 100644 --- a/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx +++ b/apps/web/src/app/admin/components/OrganizationAdmin/AddMemberDialog.tsx @@ -209,6 +209,7 @@ export function AddMemberDialog({ Member Owner + Admin Billing Manager diff --git a/apps/web/src/app/api/auto-routing/mode/route.test.ts b/apps/web/src/app/api/auto-routing/mode/route.test.ts index 861c54598f3..1d19fa39d3b 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.test.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import { NextRequest } from 'next/server'; import { @@ -93,7 +94,7 @@ describe('/api/auto-routing/mode', () => { user: expect.objectContaining({ id: USER_ID, is_admin: false }), }), ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith(ORGANIZATION_ID); expect(mockedUpdateAutoRoutingMode).toHaveBeenCalledWith({ diff --git a/apps/web/src/app/api/auto-routing/mode/route.ts b/apps/web/src/app/api/auto-routing/mode/route.ts index ef3baf512c0..56fd04cb80b 100644 --- a/apps/web/src/app/api/auto-routing/mode/route.ts +++ b/apps/web/src/app/api/auto-routing/mode/route.ts @@ -10,6 +10,7 @@ import { updateAutoRoutingMode, } from '@/lib/ai-gateway/auto-routing-admin-client'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; @@ -71,7 +72,7 @@ export async function GET(request: NextRequest): Promise { } export async function PUT(request: NextRequest): Promise { - const owner = await resolveOwner(request, ['owner', 'billing_manager']); + const owner = await resolveOwner(request, ORGANIZATION_BILLING_ROLES); if ('response' in owner) return owner.response; if (owner.ownerType === 'org') { try { diff --git a/apps/web/src/app/api/auto-routing/settings/route.test.ts b/apps/web/src/app/api/auto-routing/settings/route.test.ts index e3516e8e977..489aa055ee9 100644 --- a/apps/web/src/app/api/auto-routing/settings/route.test.ts +++ b/apps/web/src/app/api/auto-routing/settings/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import type { AutoRoutingSettingsResponse } from '@kilocode/auto-routing-contracts'; import { NextRequest } from 'next/server'; @@ -262,7 +263,7 @@ describe('/api/auto-routing/settings', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( expect.anything(), ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); }); diff --git a/apps/web/src/app/api/auto-routing/settings/route.ts b/apps/web/src/app/api/auto-routing/settings/route.ts index 318524b6057..40d0f3f1520 100644 --- a/apps/web/src/app/api/auto-routing/settings/route.ts +++ b/apps/web/src/app/api/auto-routing/settings/route.ts @@ -22,6 +22,7 @@ import { type AutoRoutingSettingsApiResponse, } from '@/lib/ai-gateway/auto-routing-pool-validation'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; @@ -156,7 +157,7 @@ export async function GET(request: NextRequest): Promise { } export async function PUT(request: NextRequest): Promise { - const owner = await resolveOwner(request, ['owner', 'billing_manager']); + const owner = await resolveOwner(request, ORGANIZATION_BILLING_ROLES); if ('response' in owner) return owner.response; if (owner.ownerType === 'org') { try { diff --git a/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts b/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts index dd9722b148b..810bb791ef6 100644 --- a/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/bitbucket/connect/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { NextRequest } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { verifyOAuthState } from '@/lib/integrations/oauth-state'; @@ -66,7 +67,7 @@ describe('GET /api/integrations/bitbucket/connect', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( { user: expect.objectContaining({ id: USER_ID }) }, ORGANIZATION_ID, - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); }); }); diff --git a/apps/web/src/app/api/integrations/linear/connect/route.test.ts b/apps/web/src/app/api/integrations/linear/connect/route.test.ts index 0ea2c4f6a0c..eac653f2df8 100644 --- a/apps/web/src/app/api/integrations/linear/connect/route.test.ts +++ b/apps/web/src/app/api/integrations/linear/connect/route.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, test } from '@jest/globals'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { NextRequest } from 'next/server'; import { getUserFromAuth } from '@/lib/user/server'; import { getLinearOAuthUrl } from '@/lib/integrations/linear-service'; @@ -93,7 +94,7 @@ describe('GET /api/integrations/linear/connect', () => { expect(mockedEnsureOrganizationAccess).toHaveBeenCalledWith( { user: expect.objectContaining({ id: USER_ID }) }, 'org-linear-123', - ['owner', 'billing_manager'] + ORGANIZATION_BILLING_ROLES ); expect(mockedRequireActiveSubscriptionOrTrial).toHaveBeenCalledWith('org-linear-123'); const state = mockedGetLinearOAuthUrl.mock.calls[0]?.[0]; diff --git a/apps/web/src/app/payments/topup/route.ts b/apps/web/src/app/payments/topup/route.ts index 6540150b70f..860038d69b1 100644 --- a/apps/web/src/app/payments/topup/route.ts +++ b/apps/web/src/app/payments/topup/route.ts @@ -7,6 +7,7 @@ import { isValidReturnUrl } from '@/lib/payment-return-url'; import { captureException } from '@sentry/nextjs'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { getAuthorizedOrgContext } from '@/lib/organizations/organization-auth'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; /** * NOTE: Crypto payment support (Coinbase Commerce) was removed in January 2026. @@ -66,7 +67,7 @@ export async function POST(request: NextRequest): Promise> let stripeCustomerId: string | null | undefined; if (organizationId) { - const orgContext = await getAuthorizedOrgContext(organizationId, ['owner', 'billing_manager']); + const orgContext = await getAuthorizedOrgContext(organizationId, ORGANIZATION_BILLING_ROLES); if (!orgContext.success) { return orgContext.nextResponse; } diff --git a/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts b/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts index 59cf8a44a63..efadf05d1fc 100644 --- a/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts +++ b/apps/web/src/components/auto-routing/AutoRoutingModeCard.test.ts @@ -973,9 +973,11 @@ describe('error messages', () => { describe('readonly org member controls', () => { it('billing-manager edit permission is isolated from page-level canEdit', () => { // Mirrors OrganizationProvidersAndModelsPage derivation. + const canManageOrganization = (role: string) => role === 'owner' || role === 'admin'; const derive = (role: string, isKiloAdmin = false) => { - const canEdit = isKiloAdmin || role === 'owner'; - const canEditAutoRouting = isKiloAdmin || role === 'owner' || role === 'billing_manager'; + const canEdit = isKiloAdmin || canManageOrganization(role); + const canEditAutoRouting = + isKiloAdmin || canManageOrganization(role) || role === 'billing_manager'; return { canEdit, canEditAutoRouting }; }; @@ -991,6 +993,10 @@ describe('readonly org member controls', () => { canEdit: true, canEditAutoRouting: true, }); + expect(derive('admin')).toEqual({ + canEdit: true, + canEditAutoRouting: true, + }); expect(derive('member', true)).toEqual({ canEdit: true, canEditAutoRouting: true, diff --git a/apps/web/src/components/organizations/BYOKContent.tsx b/apps/web/src/components/organizations/BYOKContent.tsx index 98645471f8c..d5f65c054b1 100644 --- a/apps/web/src/components/organizations/BYOKContent.tsx +++ b/apps/web/src/components/organizations/BYOKContent.tsx @@ -7,6 +7,7 @@ import { useRoleTesting } from '@/contexts/RoleTestingContext'; import { BYOKKeysManager } from './byok/BYOKKeysManager'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { AlertCircle } from 'lucide-react'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; export function BYOKContent({ organizationId, @@ -21,8 +22,8 @@ export function BYOKContent({ const currentRole = assumedRole === 'KILO ADMIN' ? 'owner' : assumedRole || role || 'member'; const isKiloAdmin = assumedRole === 'KILO ADMIN'; - // Check if user has permission to access BYOK (must be org owner) - const hasPermission = currentRole === 'owner'; + // Check if user has permission to access BYOK (must be org owner or admin) + const hasPermission = canManageOrganization(currentRole); return ( diff --git a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx index 98ae08df57c..556c05c5741 100644 --- a/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx +++ b/apps/web/src/components/organizations/FreeTrialWarningBanner.tsx @@ -11,6 +11,7 @@ import type { OrganizationWithMembers, } from '@/lib/organizations/organization-types'; import { capitalize, cn } from '@/lib/utils'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type FreeTrialWarningBannerProps = { organization: OrganizationWithMembers; @@ -125,11 +126,11 @@ export function FreeTrialWarningBanner({ const state = getOrgTrialStatusFromDays(daysRemaining); const planName = capitalize(organization.plan); const styles = getStylesForState(state, planName); - const isOwner = userRole === 'owner'; + const canManage = canManageOrganization(userRole); const buttonVariant = getButtonVariantForState(state); - const message = getTrialMessage(daysRemaining, isOwner); + const message = getTrialMessage(daysRemaining, canManage); const pathname = usePathname(); - const shouldShowUpgradeButton = isOwner && !pathname.endsWith('/subscriptions'); + const shouldShowUpgradeButton = canManage && !pathname.endsWith('/subscriptions'); return (
diff --git a/apps/web/src/components/organizations/OrganizationDashboard.tsx b/apps/web/src/components/organizations/OrganizationDashboard.tsx index 39bf4381479..b105aa3d2ea 100644 --- a/apps/web/src/components/organizations/OrganizationDashboard.tsx +++ b/apps/web/src/components/organizations/OrganizationDashboard.tsx @@ -13,7 +13,10 @@ import { OrganizationPageHeader } from './OrganizationPageHeader'; import { OrganizationWelcomeHeader } from './OrganizationWelcomeHeader'; import { NewOrganizationWelcomeHeader } from './NewOrganizationWelcomeHeader'; import { OrganizationTopupSuccessHeader } from './OrganizationTopupSuccessHeader'; -import { canManageOrganizationBilling } from '@kilocode/app-shared/organizations'; +import { + canManageOrganization, + canManageOrganizationBilling, +} from '@kilocode/app-shared/organizations'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { useRoleTesting } from '@/contexts/RoleTestingContext'; import { useOrganizationWithMembers } from '@/app/api/organizations/hooks'; @@ -156,7 +159,7 @@ export function OrganizationDashboard({ ) : ( )} @@ -179,7 +182,7 @@ export function OrganizationDashboard({ )} - {(currentRole === 'owner' || isKiloAdmin) && ( + {(canManageOrganization(currentRole) || isKiloAdmin) && ( )} diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 2d6dd551676..069e5f0202c 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -45,6 +45,7 @@ import { toast } from 'sonner'; import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only'; import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { @@ -77,7 +78,7 @@ function DailyUsageLimitDisplay({ member }: DailyUsageLimitDisplayProps) { } const canManageMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean => - isKiloAdmin || role === 'owner'; + isKiloAdmin || canManageOrganization(role); const canInviteMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean => canManageMembers(role, isKiloAdmin) || role === 'billing_manager'; diff --git a/apps/web/src/components/organizations/SSOSignupCard.tsx b/apps/web/src/components/organizations/SSOSignupCard.tsx index 01d36bd949d..47db117ccb9 100644 --- a/apps/web/src/components/organizations/SSOSignupCard.tsx +++ b/apps/web/src/components/organizations/SSOSignupCard.tsx @@ -10,6 +10,7 @@ import type { OrganizationRole, OrganizationWithMembers, } from '@/lib/organizations/organization-types'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type SSOSignupCardProps = { organization: OrganizationWithMembers; @@ -152,7 +153,7 @@ export function SSOSignupCard({ organization, role }: SSOSignupCardProps) { )}
{/* Verify Domain Link - only show to owners when domain is not verified */} - {!isDomainVerified && role === 'owner' && ( + {!isDomainVerified && canManageOrganization(role) && (
{/* Complete Setup Link - only show to owners when domain is verified */} - {isDomainVerified && role === 'owner' && ( + {isDomainVerified && canManageOrganization(role) && ( - {(['owner', 'member', 'billing_manager'] as const).map( + {(['owner', 'admin', 'member', 'billing_manager'] as const).map( (roleOption: OrganizationRole) => { const isAvailable = availableRoles.includes(roleOption); const isSelected = role === roleOption; diff --git a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx index 62a198aeb4f..b47f4defd56 100644 --- a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx +++ b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx @@ -17,6 +17,7 @@ import { } from '@/components/organizations/OrganizationContext'; import { useSession } from 'next-auth/react'; import { toast } from 'sonner'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; const getRoleBadgeVariant = (role: string) => { switch (role) { @@ -31,15 +32,19 @@ const getRoleBadgeVariant = (role: string) => { const ROLE_LABELS = { owner: 'Owner', + admin: 'Admin', member: 'Member', billing_manager: 'Billing Manager', } as const; +const ASSIGNABLE_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; + +// Owners, admins and Kilo staff may assign any role; nobody else may change roles. const getAvailableRoles = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean ): OrganizationRole[] => { - return isKiloAdmin || currentUserRole === 'owner' ? ['owner', 'member', 'billing_manager'] : []; + return isKiloAdmin || canManageOrganization(currentUserRole) ? [...ASSIGNABLE_ROLES] : []; }; type MemberRoleDropdownProps = { @@ -118,7 +123,7 @@ export function MemberRoleDropdown({ - {(['owner', 'member', 'billing_manager'] as const).map(role => { + {ASSIGNABLE_ROLES.map(role => { const isCurrentRole = member.role === role; const isAllowed = availableRoles.includes(role); const isDisabled = isCurrentRole || mutation.isPending || !isAllowed; diff --git a/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx b/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx index 21e80e1edd6..ebffb1b0d94 100644 --- a/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx +++ b/apps/web/src/components/organizations/providers-and-models/OrganizationProvidersAndModelsPage.tsx @@ -35,6 +35,7 @@ import { modelRetainsPrompts, modelTrains, } from '@/lib/ai-gateway/providers/openrouter/model-data-policy'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; type Props = { organizationId: string; @@ -95,11 +96,11 @@ export function OrganizationProvidersAndModelsPage({ organizationId, role }: Pro const { assumedRole } = useRoleTesting(); const isKiloAdmin = assumedRole === 'KILO ADMIN'; const currentRole = (isKiloAdmin ? 'owner' : assumedRole) ?? role; - const canEdit = isKiloAdmin || currentRole === 'owner'; - // Auto routing card: owners, billing managers, and platform admins may edit. + const canEdit = isKiloAdmin || canManageOrganization(currentRole); + // Auto routing card: owners, admins, billing managers, and platform admins may edit. // Do not widen page-level canEdit (provider allow-list stays owner/admin only). const canEditAutoRouting = - isKiloAdmin || currentRole === 'owner' || currentRole === 'billing_manager'; + isKiloAdmin || canManageOrganization(currentRole) || currentRole === 'billing_manager'; const updateOrganizationSettings = useUpdateOrganizationSettings(); diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index e33b16c5c21..d5edd0ebc0c 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -18,6 +18,7 @@ import { import { Download, SlidersHorizontal } from 'lucide-react'; import type { Organization } from '@kilocode/db/schema'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; +import { canManageOrganization } from '@kilocode/app-shared/organizations'; import { SummarySection } from './SummarySection'; import { PrimaryChart } from './PrimaryChart'; import { BreakdownPieChart } from './BreakdownPieChart'; @@ -717,7 +718,7 @@ export function UsageAnalyticsDashboard(props: UsageAnalyticsDashboardProps) { ) : ( diff --git a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts index 4d16edb4d0d..22a8f4fc532 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-callback.ts @@ -21,6 +21,7 @@ import { } from '@/lib/integrations/platforms/bitbucket/credentials'; import { scheduleBitbucketRepositoryCachePrime } from '@/lib/integrations/platforms/bitbucket/repository-cache'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; type CallbackState = { owner: string; returnTo?: string }; @@ -68,7 +69,7 @@ async function authorizeOwner(owner: Owner, user: AuthenticatedOAuthUser): Promi if (owner.id !== user.id) throw new Error('OAuth owner mismatch'); return; } - await ensureOrganizationAccess({ user }, owner.id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, owner.id, ORGANIZATION_BILLING_ROLES); } export async function handleBitbucketOAuthCallback(request: NextRequest): Promise { diff --git a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts index 13ed91f066c..e9dd121525f 100644 --- a/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts +++ b/apps/web/src/lib/integrations/oauth/platforms/bitbucket-connect.ts @@ -12,6 +12,7 @@ import { } from '@/lib/integrations/oauth/common'; import { validateReturnPath } from '@/lib/integrations/validate-return-path'; import { getUserFromAuth } from '@/lib/user/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; function detailPath(organizationId: string | null): string { @@ -33,7 +34,7 @@ export async function handleBitbucketOAuthConnect(request: NextRequest): Promise ? { type: 'org', id: organizationId } : { type: 'user', id: user.id }; if (owner.type === 'org') { - await ensureOrganizationAccess({ user }, owner.id, ['owner', 'billing_manager']); + await ensureOrganizationAccess({ user }, owner.id, ORGANIZATION_BILLING_ROLES); } const returnToParam = request.nextUrl.searchParams.get('returnTo'); diff --git a/apps/web/src/lib/integrations/oauth/routes.ts b/apps/web/src/lib/integrations/oauth/routes.ts index 1a8983fe34d..47b987e14d1 100644 --- a/apps/web/src/lib/integrations/oauth/routes.ts +++ b/apps/web/src/lib/integrations/oauth/routes.ts @@ -1,6 +1,7 @@ import 'server-only'; import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { PLATFORM } from '@/lib/integrations/core/constants'; import { handleStatefulPlatformOAuthConnect, @@ -38,7 +39,7 @@ const statefulOAuthConnectRouteConfigEntries = [ source: 'linear_oauth', loadBuildOAuthUrl: async () => (await import('@/lib/integrations/linear-service')).getLinearOAuthUrl, - organizationRoles: ['owner', 'billing_manager'], + organizationRoles: ORGANIZATION_BILLING_ROLES, requireActiveOrganizationSubscription: true, }, ], diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts b/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts index 74b637d4246..1f054e1d0d5 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/credentials.ts @@ -8,6 +8,7 @@ import { import { db } from '@/lib/drizzle'; import { INTEGRATION_STATUS, PLATFORM } from '@/lib/integrations/core/constants'; import type { Owner } from '@/lib/integrations/core/types'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { encryptKeyedEnvelope } from '@kilocode/encryption'; import { kilocode_users, @@ -177,7 +178,7 @@ export async function storeBitbucketIntegration(input: StoreBitbucketIntegration and( eq(organization_memberships.organization_id, input.owner.id), eq(organization_memberships.kilo_user_id, input.authorizedByUserId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .for('update'); diff --git a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts index 1754ef3ab9a..4a20374fbb1 100644 --- a/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts +++ b/apps/web/src/lib/integrations/platforms/bitbucket/workspace-access-token-organization-authorization.ts @@ -1,6 +1,7 @@ import 'server-only'; import { kilocode_users, organization_memberships, organizations } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { buildBitbucketOrganizationCredentialLockKey } from '@kilocode/worker-utils/bitbucket-workspace-access-token'; import { and, eq, inArray, isNull, sql } from 'drizzle-orm'; import type { DrizzleTransaction } from '@/lib/drizzle'; @@ -71,7 +72,7 @@ export async function requireBitbucketWorkspaceAccessTokenOrganizationManager( and( eq(organization_memberships.organization_id, organizationId), eq(organization_memberships.kilo_user_id, actorUserId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .for('update'); diff --git a/apps/web/src/lib/integrations/resolve-owner.ts b/apps/web/src/lib/integrations/resolve-owner.ts index 0196dea74f6..863552e4e89 100644 --- a/apps/web/src/lib/integrations/resolve-owner.ts +++ b/apps/web/src/lib/integrations/resolve-owner.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import type { Owner } from './core/types'; import type { TRPCContext } from '@/lib/trpc/init'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; @@ -27,7 +28,7 @@ export async function ensureIntegrationAccess( roles?: OrganizationRole[] ) { if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, roles ?? ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, roles ?? ORGANIZATION_BILLING_ROLES); } } diff --git a/apps/web/src/lib/kilo-pass-org/notifications.ts b/apps/web/src/lib/kilo-pass-org/notifications.ts index 836ebf87371..2740b14bad5 100644 --- a/apps/web/src/lib/kilo-pass-org/notifications.ts +++ b/apps/web/src/lib/kilo-pass-org/notifications.ts @@ -8,6 +8,7 @@ import { organization_memberships, organizations, } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { and, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm'; import { captureException } from '@sentry/nextjs'; import { db } from '@/lib/drizzle'; @@ -90,7 +91,7 @@ export async function dispatchOrganizationPassBlockedNotifications( .where( and( inArray(kilo_pass_org_notification_deliveries.id, claimedIds), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ); diff --git a/apps/web/src/lib/kilo-pass-org/service.ts b/apps/web/src/lib/kilo-pass-org/service.ts index 042dd210278..fa3a678ea61 100644 --- a/apps/web/src/lib/kilo-pass-org/service.ts +++ b/apps/web/src/lib/kilo-pass-org/service.ts @@ -15,6 +15,7 @@ import { organization_seats_purchases, organizations, } from '@kilocode/db/schema'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { KiloPassOrgAgreementState, KiloPassOrgBonusMode, @@ -643,7 +644,7 @@ async function issue( .where( and( eq(organization_memberships.organization_id, row.agreement.parent_organization_id), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ); const recipientIds = new Set(recipients.map(recipient => recipient.userId)); @@ -1343,7 +1344,7 @@ async function agreementRecipientUserId( .where( and( eq(organization_memberships.organization_id, parentOrganizationId), - inArray(organization_memberships.role, ['owner', 'billing_manager']) + inArray(organization_memberships.role, ORGANIZATION_BILLING_ROLES) ) ) .orderBy(asc(organization_memberships.joined_at)) diff --git a/apps/web/src/lib/organizations/organization-auth.ts b/apps/web/src/lib/organizations/organization-auth.ts index 6101f1d92ac..817671dec6a 100644 --- a/apps/web/src/lib/organizations/organization-auth.ts +++ b/apps/web/src/lib/organizations/organization-auth.ts @@ -14,8 +14,14 @@ import z from 'zod'; const warnInSentry = sentryLogger('org_auth', 'warning'); -const parentOrganizationAccessRoles = ['owner', 'billing_manager'] satisfies OrganizationRole[]; -const rolePriority = ['owner', 'billing_manager', 'member'] satisfies OrganizationRole[]; +// Mirrors routers/organizations/utils.ts: parent-organization roles that +// inherit access to child organizations. +const parentOrganizationAccessRoles = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; +const rolePriority = ['owner', 'admin', 'billing_manager', 'member'] satisfies OrganizationRole[]; type UserWithRole = User & { readonly role: OrganizationRole; diff --git a/apps/web/src/lib/organizations/organization-shared-utils.tsx b/apps/web/src/lib/organizations/organization-shared-utils.tsx index 2fc42f52fe0..05a57a09c23 100644 --- a/apps/web/src/lib/organizations/organization-shared-utils.tsx +++ b/apps/web/src/lib/organizations/organization-shared-utils.tsx @@ -26,6 +26,10 @@ export const getRoleLabel = (role: string) => { switch (role) { case 'owner': return 'Owner'; + case 'admin': + return 'Admin'; + case 'billing_manager': + return 'Billing Manager'; case 'member': return 'Member'; default: diff --git a/apps/web/src/routers/byok-router.ts b/apps/web/src/routers/byok-router.ts index 78f0ddceacc..dda4d084956 100644 --- a/apps/web/src/routers/byok-router.ts +++ b/apps/web/src/routers/byok-router.ts @@ -1,4 +1,5 @@ import { baseProcedure, createTRPCRouter } from '@/lib/trpc/init'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -158,7 +159,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Encrypt the API key @@ -210,7 +211,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Verify key exists and belongs to the organization or user @@ -294,7 +295,7 @@ export const byokRouter = createTRPCRouter({ const { organizationId, id, is_enabled } = input; if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } const [existingKey] = await db @@ -372,7 +373,7 @@ export const byokRouter = createTRPCRouter({ const { organizationId, id } = input; if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } const [existingKey] = await db @@ -477,7 +478,7 @@ export const byokRouter = createTRPCRouter({ // If organizationId provided, verify owner/billing access if (organizationId) { - await ensureOrganizationAccess(ctx, organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_BILLING_ROLES); } // Verify key exists and belongs to the organization or user diff --git a/apps/web/src/routers/code-reviews/code-review-analytics-router.ts b/apps/web/src/routers/code-reviews/code-review-analytics-router.ts index b719b8a4783..0e980e7d07e 100644 --- a/apps/web/src/routers/code-reviews/code-review-analytics-router.ts +++ b/apps/web/src/routers/code-reviews/code-review-analytics-router.ts @@ -5,6 +5,7 @@ import { setReviewAnalyticsEnabled } from '@/lib/code-reviews/analytics/settings import { readDb } from '@/lib/drizzle'; import { createTRPCRouter, baseProcedure } from '@/lib/trpc/init'; import { timedUsageQuery } from '@/lib/usage-query'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; const DAY_IN_MS = 24 * 60 * 60 * 1000; @@ -55,7 +56,7 @@ export const codeReviewAnalyticsRouter = createTRPCRouter({ }), setEnabled: baseProcedure.input(SetEnabledInputSchema).mutation(async ({ ctx, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); const enabled = await setReviewAnalyticsEnabled({ owner: { type: 'org', id: input.organizationId }, diff --git a/apps/web/src/routers/code-reviews/review-memory-router.ts b/apps/web/src/routers/code-reviews/review-memory-router.ts index dac9e6b934c..dd48bab8098 100644 --- a/apps/web/src/routers/code-reviews/review-memory-router.ts +++ b/apps/web/src/routers/code-reviews/review-memory-router.ts @@ -21,6 +21,7 @@ import { ReviewMemoryChangeRequestError, } from '@/lib/code-reviews/review-memory/change-request'; import { getAgentConfigForOwner } from '@/lib/agent-config/db/agent-configs'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { REVIEW_MEMORY_PROPOSAL_STATUSES } from '@kilocode/db/schema-types'; @@ -86,7 +87,7 @@ export const reviewMemoryRouter = createTRPCRouter({ setEnabled: baseProcedure .input(PlatformOwnerInputSchema.extend({ enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); const enabled = await setReviewMemoryEnabled({ owner, platform: input.platform, @@ -99,7 +100,7 @@ export const reviewMemoryRouter = createTRPCRouter({ triggerAnalysis: baseProcedure .input(PlatformOwnerInputSchema.extend({ repoFullName: z.string().min(1) })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); return await runReviewMemoryAnalysis({ owner, @@ -119,7 +120,7 @@ export const reviewMemoryRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); const proposal = await updateProposal({ owner, @@ -135,7 +136,7 @@ export const reviewMemoryRouter = createTRPCRouter({ rejectProposal: baseProcedure .input(PlatformOwnerInputSchema.extend({ proposalId: z.uuid() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); const proposal = await rejectProposal({ owner, proposalId: input.proposalId }); if (!proposal) throw new TRPCError({ code: 'NOT_FOUND', message: 'Proposal not found.' }); @@ -145,7 +146,7 @@ export const reviewMemoryRouter = createTRPCRouter({ approveAndOpenChangeRequest: baseProcedure .input(PlatformOwnerInputSchema.extend({ proposalId: z.uuid() })) .mutation(async ({ ctx, input }) => { - const owner = await ownerFromInput(ctx, input, ['owner', 'billing_manager']); + const owner = await ownerFromInput(ctx, input, ORGANIZATION_BILLING_ROLES); await assertEnabled(owner, input.platform); try { return await approveAndOpenReviewMemoryChangeRequest({ diff --git a/apps/web/src/routers/gitlab-router.ts b/apps/web/src/routers/gitlab-router.ts index 912d4fa2c65..fe2a5782a4a 100644 --- a/apps/web/src/routers/gitlab-router.ts +++ b/apps/web/src/routers/gitlab-router.ts @@ -4,6 +4,7 @@ import { TRPCError } from '@trpc/server'; import * as z from 'zod'; import * as gitlabService from '@/lib/integrations/gitlab-service'; import { getValidGitLabToken } from '@/lib/integrations/gitlab-service'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess } from '@/routers/organizations/utils'; import { resolveOwner, @@ -69,7 +70,7 @@ export const gitlabRouter = createTRPCRouter({ ) .mutation(async ({ ctx, input }) => { if (input.organizationId) { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); } const owner = resolveOwner(ctx, input.organizationId); return gitlabService.connectWithPAT(owner, input.token, input.instanceUrl, ctx.user.id); diff --git a/apps/web/src/routers/organizations/organization-admin-router.ts b/apps/web/src/routers/organizations/organization-admin-router.ts index df6d79e939e..b13420e84a9 100644 --- a/apps/web/src/routers/organizations/organization-admin-router.ts +++ b/apps/web/src/routers/organizations/organization-admin-router.ts @@ -39,6 +39,7 @@ import { addUserToOrganization, markOrganizationAsDeleted, } from '@/lib/organizations/organizations'; +import { OrganizationRoleSchema } from '@/lib/organizations/organization-types'; import { getOrCreateStripeCustomerIdForOrganization } from '@/lib/organizations/organization-billing'; import { findUserById } from '@/lib/user'; import { TRPCError } from '@trpc/server'; @@ -240,7 +241,7 @@ const OrganizationMetricsSchema = z.object({ const AddMemberInputSchema = z.object({ organizationId: z.uuid(), userId: z.string(), - role: z.enum(['owner', 'member', 'billing_manager']), + role: OrganizationRoleSchema, }); const childOrganizationSettings = { diff --git a/apps/web/src/routers/organizations/organization-groups-router.ts b/apps/web/src/routers/organizations/organization-groups-router.ts index d63faa76b7a..9a0f443bb4a 100644 --- a/apps/web/src/routers/organizations/organization-groups-router.ts +++ b/apps/web/src/routers/organizations/organization-groups-router.ts @@ -28,7 +28,7 @@ import { OrganizationIdInputSchema, organizationBillingProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { getModelAccessPolicyEditorData } from '@/lib/organizations/group-policies/model-access/model-access.server'; @@ -108,7 +108,7 @@ export const organizationGroupsRouter = createTRPCRouter({ return { group }; }), - create: organizationOwnerMutationProcedure + create: organizationAdminMutationProcedure .input(CreateGroupInputSchema) .mutation(async ({ input, ctx }) => { return await createOrganizationGroup({ @@ -120,7 +120,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - updateMetadata: organizationOwnerMutationProcedure + updateMetadata: organizationAdminMutationProcedure .input(UpdateMetadataInputSchema) .mutation(async ({ input, ctx }) => { return await updateOrganizationGroupMetadata({ @@ -132,7 +132,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - updateDetails: organizationOwnerMutationProcedure + updateDetails: organizationAdminMutationProcedure .input(UpdateGroupDetailsInputSchema) .mutation(async ({ input, ctx }) => { return await updateOrganizationGroupDetails({ @@ -145,7 +145,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setPolicy: organizationOwnerMutationProcedure + setPolicy: organizationAdminMutationProcedure .input(SetPolicyInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationGroupPolicy({ @@ -156,7 +156,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - removePolicy: organizationOwnerMutationProcedure + removePolicy: organizationAdminMutationProcedure .input(RemovePolicyInputSchema) .mutation(async ({ input, ctx }) => { return await removeOrganizationGroupPolicy({ @@ -167,7 +167,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setMembers: organizationOwnerMutationProcedure + setMembers: organizationAdminMutationProcedure .input(SetMembersInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationGroupMembers({ @@ -178,7 +178,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - setMemberGroups: organizationOwnerMutationProcedure + setMemberGroups: organizationAdminMutationProcedure .input(SetMemberGroupsInputSchema) .mutation(async ({ input, ctx }) => { return await setOrganizationMemberGroups({ @@ -189,7 +189,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - delete: organizationOwnerMutationProcedure + delete: organizationAdminMutationProcedure .input(GroupIdInputSchema) .mutation(async ({ input, ctx }) => { return await deleteOrganizationGroup({ @@ -216,7 +216,7 @@ export const organizationGroupsRouter = createTRPCRouter({ } }), - setDefaultPolicy: organizationOwnerMutationProcedure + setDefaultPolicy: organizationAdminMutationProcedure .input(SetDefaultPolicyInputSchema) .mutation(async ({ input, ctx }) => { return await setDefaultOrganizationGroupPolicy({ @@ -226,7 +226,7 @@ export const organizationGroupsRouter = createTRPCRouter({ }); }), - removeDefaultPolicy: organizationOwnerMutationProcedure + removeDefaultPolicy: organizationAdminMutationProcedure .input( OrganizationIdInputSchema.extend({ policyType: OrganizationGroupPolicyTypeSchema }).strict() ) diff --git a/apps/web/src/routers/organizations/organization-members-router.test.ts b/apps/web/src/routers/organizations/organization-members-router.test.ts index 76ae1df3312..a3672f3bafe 100644 --- a/apps/web/src/routers/organizations/organization-members-router.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.test.ts @@ -32,6 +32,8 @@ let adminUser: User; let memberUser: User; let billingManagerUser: User; let nonMemberUser: User; +// Holds the `admin` organization role (distinct from `adminUser`, who is Kilo staff). +let orgAdminUser: User; let testOrganization: Organization; describe('organizations members trpc router', () => { @@ -67,12 +69,19 @@ describe('organizations members trpc router', () => { is_admin: false, }); + orgAdminUser = await insertTestUser({ + google_user_email: 'org-admin-members@example.com', + google_user_name: 'Org Admin Members User', + is_admin: false, + }); + // Create test organization using the CRUD method testOrganization = await createOrganization('Test Members Organization', regularUser.id); // Add member user to organization using CRUD method await addUserToOrganization(testOrganization.id, memberUser.id, 'member'); await addUserToOrganization(testOrganization.id, billingManagerUser.id, 'billing_manager'); + await addUserToOrganization(testOrganization.id, orgAdminUser.id, 'admin'); }); describe('listPublic procedure', () => { @@ -182,6 +191,63 @@ describe('organizations members trpc router', () => { ).rejects.toThrow('You cannot change your own role'); }); + it('lets an organization admin update a member role, matching owner authority', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-update.example.com`, + google_user_name: 'Org Admin Update Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'billing_manager', + }); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + }); + + it('lets an organization admin grant the owner role, since admin is an owner peer', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-promote.example.com`, + google_user_name: 'Org Admin Promote Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'owner', + }); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + }); + + it('lets an organization admin change an existing owner role', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-demote.example.com`, + google_user_name: 'Org Admin Demote Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'member', + }); + + expect(result).toEqual({ success: true, updated: 'role and limit' }); + }); + it('should throw FORBIDDEN error when non-owner tries to assign owner role', async () => { // Create a test user to be the target of the role update const targetUser = await insertTestUser({ @@ -603,6 +669,24 @@ describe('organizations members trpc router', () => { ).rejects.toThrow('You do not have access to this organization'); }); + it('lets an organization admin remove an owner, matching owner authority', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-remove-owner.example.com`, + google_user_name: 'Org Admin Remove Owner Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.remove({ + organizationId: testOrganization.id, + memberId: targetUser.id, + }); + + expect(result).toEqual({ success: true, updated: targetUser.id }); + }); + it('should reject billing managers removing owners', async () => { const caller = await createCallerForUser(billingManagerUser.id); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index 36400e13c47..40ade3d0336 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -21,8 +21,9 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { sendOrganizationInviteEmail } from '@/lib/email'; import { TRPCError } from '@trpc/server'; import { and, eq, inArray, isNull } from 'drizzle-orm'; @@ -33,13 +34,17 @@ import { successResult } from '@/lib/maybe-result'; import { destroyOrgInstancesForUser } from '@/lib/kiloclaw/instance-registry'; import { KiloClawInternalClient } from '@/lib/kiloclaw/kiloclaw-internal-client'; import { revokeGatewayStateForOrganizationMember } from '@/lib/mcp-gateway/lifecycle-service'; -import { PublicOrganizationMembersSchema } from '@/lib/organizations/organization-types'; +import { + OrganizationRoleSchema, + PublicOrganizationMembersSchema, +} from '@/lib/organizations/organization-types'; +import type { OrganizationRole } from '@/lib/organizations/organization-types'; const MAX_DAILY_LIMIT_USD = 2000; const UpdateMemberSchema = OrganizationIdInputSchema.extend({ memberId: z.string(), - role: z.enum(['owner', 'member', 'billing_manager']).optional(), + role: OrganizationRoleSchema.optional(), dailyUsageLimitUsd: z.number().min(0).max(MAX_DAILY_LIMIT_USD).nullable().optional(), }); @@ -49,7 +54,7 @@ const RemoveMemberSchema = OrganizationIdInputSchema.extend({ const InviteMemberSchema = OrganizationIdInputSchema.extend({ email: z.email('Invalid email address'), - role: z.enum(['owner', 'member', 'billing_manager']), + role: OrganizationRoleSchema, }); const DeleteInviteSchema = OrganizationIdInputSchema.extend({ @@ -64,7 +69,7 @@ const SetChildMembershipsSchema = OrganizationIdInputSchema.extend({ async function getDirectOrganizationRole( organizationId: string, userId: string -): Promise<'owner' | 'member' | 'billing_manager' | null> { +): Promise { const [membership] = await db .select({ role: organization_memberships.role }) .from(organization_memberships) @@ -87,14 +92,14 @@ export const organizationsMembersRouter = createTRPCRouter({ return await getOrganizationMembers(input.organizationId); }), - update: organizationOwnerMutationProcedure + update: organizationAdminMutationProcedure .input(UpdateMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; const { organizationId, memberId, role, dailyUsageLimitUsd } = input; // Get the target user's role if we need to check permissions for role or limit changes - let targetMember: { role: string } | undefined; + let targetMember: { role: OrganizationRole } | undefined; if (role !== undefined || dailyUsageLimitUsd !== undefined) { const [member] = await db .select({ role: organization_memberships.role }) @@ -283,7 +288,7 @@ export const organizationsMembersRouter = createTRPCRouter({ return successResult({ added, removed }); }), - remove: organizationOwnerMutationProcedure + remove: organizationAdminMutationProcedure .input(RemoveMemberSchema) .mutation(async ({ input, ctx }) => { const { user } = ctx; @@ -389,8 +394,10 @@ export const organizationsMembersRouter = createTRPCRouter({ const { user } = ctx; const { organizationId, email, role } = input; + // Members can be invited by any billing-capable role; elevated roles + // require organization-management authority. if (role !== 'member') { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } // Get organization details @@ -402,7 +409,8 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners and Kilo admins can invite any role. Billing managers can invite members only. + // Owners, admins and Kilo staff can invite any role. Billing managers can + // invite members only. let invitation; try { invitation = await inviteUserToOrganization(organizationId, user.id, email, role); @@ -476,7 +484,7 @@ export const organizationsMembersRouter = createTRPCRouter({ acceptInviteUrl, }; }), - deleteInvite: organizationOwnerMutationProcedure + deleteInvite: organizationAdminMutationProcedure .input(DeleteInviteSchema) .mutation(async ({ input, ctx }) => { const { organizationId, inviteId } = input; @@ -500,7 +508,7 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners can delete any invitation + // Owners and admins can revoke any invitation // Expire the invitation by setting expires_at to NOW await db .update(organization_invitations) diff --git a/apps/web/src/routers/organizations/organization-modes-router.ts b/apps/web/src/routers/organizations/organization-modes-router.ts index 8854d0c73f2..47af1aac5fa 100644 --- a/apps/web/src/routers/organizations/organization-modes-router.ts +++ b/apps/web/src/routers/organizations/organization-modes-router.ts @@ -5,6 +5,7 @@ import { organizationMemberProcedure, organizationMemberMutationProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; import { @@ -98,7 +99,7 @@ async function applyOrganizationAutoRouteChange( ): Promise { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organization.id, ['owner']); + await ensureOrganizationAccess(ctx, organization.id, ORGANIZATION_MANAGE_ROLES); const orgAutoModel = getOrganizationAutoSettings(organization.settings); const currentRoute = orgAutoModel.routes[modeSlug]; if (routeModel === null && !currentRoute) { @@ -225,7 +226,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } let createdMode: OrganizationMode | null | undefined; @@ -334,7 +335,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } const hasModeUpdates = @@ -365,7 +366,7 @@ export const organizationModesRouter = createTRPCRouter({ let nextSettings = lockedOrganization.settings; if (sourceHasRoute && slugChanged) { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); if (hasOrganizationAutoRoute(routes, nextSlug)) { throw new TRPCError({ code: 'CONFLICT', @@ -477,7 +478,7 @@ export const organizationModesRouter = createTRPCRouter({ if (route_model !== undefined) { await assertOrganizationAutoWriteEnabled(ctx.user.id); assertOrganizationAutoEligible(organization); - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); } const routeAuditChanges: string[] = []; @@ -525,7 +526,7 @@ export const organizationModesRouter = createTRPCRouter({ ); } } else if (hasExistingRoute) { - await ensureOrganizationAccess(ctx, organizationId, ['owner']); + await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); if (!preserveBuiltInRoute) { routeAuditChanges.push( diff --git a/apps/web/src/routers/organizations/organization-settings-router.ts b/apps/web/src/routers/organizations/organization-settings-router.ts index dd5b3e22430..3f333d06ff9 100644 --- a/apps/web/src/routers/organizations/organization-settings-router.ts +++ b/apps/web/src/routers/organizations/organization-settings-router.ts @@ -13,7 +13,7 @@ import { OrganizationIdInputSchema, organizationBillingMutationProcedure, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { TRPCError } from '@trpc/server'; import * as z from 'zod'; @@ -337,7 +337,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return result; }), - updateAllowLists: organizationOwnerMutationProcedure + updateAllowLists: organizationAdminMutationProcedure .input(UpdateAllowListsInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -406,7 +406,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - updateDefaultModel: organizationOwnerMutationProcedure + updateDefaultModel: organizationAdminMutationProcedure .input(UpdateDefaultModelInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -476,7 +476,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - setOrganizationAutoRoute: organizationOwnerMutationProcedure + setOrganizationAutoRoute: organizationAdminMutationProcedure .input(SetOrganizationAutoRouteInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -544,7 +544,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - clearOrganizationAutoRoute: organizationOwnerMutationProcedure + clearOrganizationAutoRoute: organizationAdminMutationProcedure .input(ClearOrganizationAutoRouteInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -598,7 +598,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ return { settings: updatedSettings }; }), - configureOrganizationDefaultBehavior: organizationOwnerMutationProcedure + configureOrganizationDefaultBehavior: organizationAdminMutationProcedure .input(ConfigureOrganizationDefaultBehaviorInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { @@ -892,7 +892,7 @@ export const organizationsSettingsRouter = createTRPCRouter({ // Owners-only: toggle the weekly enterprise recommendations digest email. // Enterprise-gated and owner-only (matching the recommendations dismiss/restore // permission model). When on, the digest is emailed to the org's owners. - updateRecommendationsDigest: organizationOwnerMutationProcedure + updateRecommendationsDigest: organizationAdminMutationProcedure .input(UpdateRecommendationsDigestInputSchema) .output(SettingsResponseSchema) .mutation(async ({ input, ctx }) => { diff --git a/apps/web/src/routers/organizations/organization-sso-router.ts b/apps/web/src/routers/organizations/organization-sso-router.ts index 3ce5a2e1e6c..4b61c156d13 100644 --- a/apps/web/src/routers/organizations/organization-sso-router.ts +++ b/apps/web/src/routers/organizations/organization-sso-router.ts @@ -6,6 +6,7 @@ import { ensureOrganizationAccess, organizationMemberProcedure, } from '@/routers/organizations/utils'; +import { ORGANIZATION_MANAGE_ROLES } from '@kilocode/app-shared/organizations'; import { TRPCError } from '@trpc/server'; import { GeneratePortalLinkIntent, WorkOS, OrganizationDomainState } from '@workos-inc/node'; import * as z from 'zod'; @@ -50,7 +51,7 @@ async function hasWorkOsConnections(organizationId: string) { export const organizationSsoRouter = createTRPCRouter({ createConfig: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (workOSOrg) { throw new TRPCError({ @@ -100,7 +101,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), deleteConfig: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (!workOSOrg) { throw new TRPCError({ code: 'NOT_FOUND', message: 'SSO configuration not found' }); @@ -110,7 +111,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), generateAdminPortalLink: adminProcedure.input(AdminPortalSchema).mutation(async opts => { const { organizationId, linkType } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); const workOSOrg = await getWorkOsOrganizationByExternalId(organizationId); if (!workOSOrg) { throw new TRPCError({ code: 'NOT_FOUND', message: 'SSO configuration not found' }); @@ -129,7 +130,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), updateSsoDomain: adminProcedure.input(UpdateSSODomainSchema).mutation(async opts => { const { organizationId, ssoDomain } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); await db.transaction(async tx => { const [organization] = await tx @@ -182,7 +183,7 @@ export const organizationSsoRouter = createTRPCRouter({ }), clearSsoDomain: adminProcedure.input(OrgIdSchema).mutation(async opts => { const { organizationId } = opts.input; - await ensureOrganizationAccess(opts.ctx, organizationId, ['owner']); + await ensureOrganizationAccess(opts.ctx, organizationId, ORGANIZATION_MANAGE_ROLES); await db .update(organizations) diff --git a/apps/web/src/routers/organizations/organization-usage-details-router.ts b/apps/web/src/routers/organizations/organization-usage-details-router.ts index 6087a352cee..369ddeb2d85 100644 --- a/apps/web/src/routers/organizations/organization-usage-details-router.ts +++ b/apps/web/src/routers/organizations/organization-usage-details-router.ts @@ -2,7 +2,7 @@ import { createTRPCRouter } from '@/lib/trpc/init'; import { OrganizationIdInputSchema, organizationMemberProcedure, - organizationOwnerMutationProcedure, + organizationAdminMutationProcedure, } from '@/routers/organizations/utils'; import { db, readDb } from '@/lib/drizzle'; import { organizations, organization_recommendation_dismissals } from '@kilocode/db/schema'; @@ -153,7 +153,7 @@ export const organizationsUsageDetailsRouter = createTRPCRouter({ } return { checks: result.checks, recommendations: result.recommendations }; }), - dismissRecommendation: organizationOwnerMutationProcedure + dismissRecommendation: organizationAdminMutationProcedure .input(DismissRecommendationInputSchema) .mutation(async ({ ctx, input }) => { await assertEnterprise(input.organizationId); @@ -172,7 +172,7 @@ export const organizationsUsageDetailsRouter = createTRPCRouter({ }); return { dismissed: true }; }), - restoreRecommendation: organizationOwnerMutationProcedure + restoreRecommendation: organizationAdminMutationProcedure .input(DismissRecommendationInputSchema) .mutation(async ({ input }) => { await assertEnterprise(input.organizationId); diff --git a/apps/web/src/routers/organizations/utils.ts b/apps/web/src/routers/organizations/utils.ts index 63cf0528759..0fefeec5826 100644 --- a/apps/web/src/routers/organizations/utils.ts +++ b/apps/web/src/routers/organizations/utils.ts @@ -3,6 +3,10 @@ import { organization_memberships, organizations } from '@kilocode/db/schema'; import { db } from '@/lib/drizzle'; import type { OrganizationRole } from '@/lib/organizations/organization-types'; import { requireActiveSubscriptionOrTrial } from '@/lib/organizations/trial-middleware'; +import { + ORGANIZATION_BILLING_ROLES, + ORGANIZATION_MANAGE_ROLES, +} from '@kilocode/app-shared/organizations'; import { baseProcedure } from '@/lib/trpc/init'; import type { TRPCContext } from '@/lib/trpc/init'; import { TRPCError } from '@trpc/server'; @@ -13,8 +17,15 @@ export const OrganizationIdInputSchema = z.object({ organizationId: z.uuid(), }); -const parentOrganizationAccessRoles = ['owner', 'billing_manager'] satisfies OrganizationRole[]; -const rolePriority = ['owner', 'billing_manager', 'member'] satisfies OrganizationRole[]; +// Roles in a parent organization that inherit access to its children. Kept +// separate from ORGANIZATION_BILLING_ROLES so changing billing scope cannot +// silently widen child-organization inheritance. +const parentOrganizationAccessRoles = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; +const rolePriority = ['owner', 'admin', 'billing_manager', 'member'] satisfies OrganizationRole[]; function allowedRole( rows: { role: OrganizationRole }[], @@ -262,19 +273,19 @@ export const organizationMemberMutationProcedure = baseProcedure return next(); }); -// Custom procedure that ensures user has owner access to the organization -export const organizationOwnerProcedure = baseProcedure +// Ensures the caller can manage the organization (owner or admin). +export const organizationAdminProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_MANAGE_ROLES); return next(); }); -// Owner procedure that also enforces trial/subscription status on mutations -export const organizationOwnerMutationProcedure = baseProcedure +// Owner/admin procedure that also enforces trial/subscription status on mutations +export const organizationAdminMutationProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_MANAGE_ROLES); await requireActiveSubscriptionOrTrial(input.organizationId); return next(); }); @@ -283,7 +294,7 @@ export const organizationOwnerMutationProcedure = baseProcedure export const organizationBillingProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); return next(); }); @@ -291,7 +302,7 @@ export const organizationBillingProcedure = baseProcedure export const organizationBillingMutationProcedure = baseProcedure .input(OrganizationIdInputSchema) .use(async ({ ctx, next, input }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); await requireActiveSubscriptionOrTrial(input.organizationId); return next(); }); diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 6022d086f6e..519bdbe14fb 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -16,6 +16,7 @@ import { user_auth_provider, } from '@kilocode/db/schema'; import type { AuthProviderId } from '@kilocode/db/schema-types'; +import { ORGANIZATION_BILLING_ROLES } from '@kilocode/app-shared/organizations'; import { ensureOrganizationAccess, ensureOrganizationsAccess, @@ -221,18 +222,13 @@ async function ensureScopeAccess(ctx: TRPCContext, filters: UsageAnalyticsFilter // Batched into a fixed number of queries so a large org list cannot fan out // into one authorization query per id. if (filters.organizationIds && filters.organizationIds.length > 0) { - await ensureOrganizationsAccess(ctx, filters.organizationIds, ['owner', 'billing_manager']); + await ensureOrganizationsAccess(ctx, filters.organizationIds, ORGANIZATION_BILLING_ROLES); return; } if (filters.organizationId) { - const requiredRoles = - filters.viewAs === 'org-wide' ? (['owner', 'billing_manager'] as const) : undefined; - await ensureOrganizationAccess( - ctx, - filters.organizationId, - requiredRoles ? [...requiredRoles] : undefined - ); + const requiredRoles = filters.viewAs === 'org-wide' ? ORGANIZATION_BILLING_ROLES : undefined; + await ensureOrganizationAccess(ctx, filters.organizationId, requiredRoles); if (filters.viewAs === 'self') { const allUserFilterValues = [...(filters.userIds ?? []), ...(filters.excludedUserIds ?? [])]; @@ -1030,7 +1026,7 @@ export const usageAnalyticsRouter = createTRPCRouter({ .input(ScopeOrganizationsInputSchema) .output(ScopeOrganizationsOutputSchema) .query(async ({ input, ctx }) => { - await ensureOrganizationAccess(ctx, input.organizationId, ['owner', 'billing_manager']); + await ensureOrganizationAccess(ctx, input.organizationId, ORGANIZATION_BILLING_ROLES); const [org] = await readDb .select({ id: organizations.id, name: organizations.name }) diff --git a/packages/app-shared/src/organizations/roles.test.ts b/packages/app-shared/src/organizations/roles.test.ts index e1655dfb8c9..36396668229 100644 --- a/packages/app-shared/src/organizations/roles.test.ts +++ b/packages/app-shared/src/organizations/roles.test.ts @@ -1,22 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { canManageOrganizationBilling, ORGANIZATION_ROLES } from './roles'; +import { canManageOrganization, canManageOrganizationBilling, ORGANIZATION_ROLES } from './roles'; describe('ORGANIZATION_ROLES', () => { - it('is exactly owner, member, billing_manager', () => { - expect(ORGANIZATION_ROLES).toEqual(['owner', 'member', 'billing_manager']); + it('is exactly owner, admin, member, billing_manager', () => { + expect(ORGANIZATION_ROLES).toEqual(['owner', 'admin', 'member', 'billing_manager']); }); }); describe('canManageOrganizationBilling', () => { - it('is true for owner and billing_manager', () => { + it('is true for owner, admin and billing_manager', () => { expect(canManageOrganizationBilling('owner')).toBe(true); + expect(canManageOrganizationBilling('admin')).toBe(true); expect(canManageOrganizationBilling('billing_manager')).toBe(true); }); it('is false for member, undefined, and unrelated strings', () => { expect(canManageOrganizationBilling('member')).toBe(false); expect(canManageOrganizationBilling(undefined)).toBe(false); - expect(canManageOrganizationBilling('admin')).toBe(false); + expect(canManageOrganizationBilling('kilo_staff')).toBe(false); + }); +}); + +describe('canManageOrganization', () => { + it('treats admin as an exact peer of owner', () => { + expect(canManageOrganization('owner')).toBe(true); + expect(canManageOrganization('admin')).toBe(true); + }); + + it('is false for billing_manager, member and undefined', () => { + expect(canManageOrganization('billing_manager')).toBe(false); + expect(canManageOrganization('member')).toBe(false); + expect(canManageOrganization(undefined)).toBe(false); }); }); diff --git a/packages/app-shared/src/organizations/roles.ts b/packages/app-shared/src/organizations/roles.ts index 1ca84e32f5c..ff5db3b4dd8 100644 --- a/packages/app-shared/src/organizations/roles.ts +++ b/packages/app-shared/src/organizations/roles.ts @@ -1,22 +1,41 @@ // Canonical organization role values. Matches apps/web's // OrganizationRoleSchema (lib/organizations/organization-types.ts, a -// z.enum(['owner', 'member', 'billing_manager'])) and packages/db's -// OrganizationRole type ('owner' | 'member' | 'billing_manager') — verified -// identical while porting. Mobile's local string-union copies (e.g. -// lib/security-agent.ts) also match, just declared in a different member -// order (order doesn't affect the union type). -export const ORGANIZATION_ROLES = ['owner', 'member', 'billing_manager'] as const; +// z.enum(ORGANIZATION_ROLES)) and packages/db's OrganizationRole type. +// Mobile's local string-union copies (e.g. lib/security-agent.ts) do not yet +// include 'admin'; mobile support for the admin role is tracked separately. +export const ORGANIZATION_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; export type OrganizationRole = (typeof ORGANIZATION_ROLES)[number]; /** - * True for roles that may manage organization billing (owner or + * Roles with full organization-management authority. `admin` is an exact peer + * of `owner`: there is no action an owner can take that an admin cannot. + */ +export const ORGANIZATION_MANAGE_ROLES = ['owner', 'admin'] satisfies OrganizationRole[]; + +/** + * Roles that may manage organization billing. Admins are included because they + * hold the same authority as owners. + */ +export const ORGANIZATION_BILLING_ROLES = [ + 'owner', + 'admin', + 'billing_manager', +] satisfies OrganizationRole[]; + +export function canManageOrganization(role: string | undefined): boolean { + return ORGANIZATION_MANAGE_ROLES.some(allowed => allowed === role); +} + +/** + * True for roles that may manage organization billing (owner, admin or * billing_manager). Ported from web's `canManageBilling` * (components/organizations/subscription/utils.ts) and mobile's * `isMoneyRole` (lib/hooks/use-organization-queries.ts) — both had the - * identical `role === 'owner' || role === 'billing_manager'` check. + * identical `role === 'owner' || role === 'billing_manager'` check; `admin` + * was added because admins hold the same authority as owners. */ export function canManageOrganizationBilling(role: string | undefined): boolean { - return role === 'owner' || role === 'billing_manager'; + return ORGANIZATION_BILLING_ROLES.some(allowed => allowed === role); } // No shared role-label map: web's getRoleLabel (organization-shared-utils.tsx) diff --git a/packages/db/src/schema-types.ts b/packages/db/src/schema-types.ts index 24dc00d1f4a..9fbeb59bef8 100644 --- a/packages/db/src/schema-types.ts +++ b/packages/db/src/schema-types.ts @@ -787,7 +787,7 @@ export type ContributorChampionTier = // --- Organization types --- -export type OrganizationRole = 'owner' | 'member' | 'billing_manager'; +export type OrganizationRole = 'owner' | 'admin' | 'member' | 'billing_manager'; export const OrganizationPlanSchema = z.enum(['teams', 'enterprise']); From 348de2b20ca1a62d8b35af18a88f496c69d60758 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 4 Aug 2026 11:21:58 -0500 Subject: [PATCH 2/5] feat(organizations): reserve owner authority for owners Admins match owners everywhere except owner management: an admin may not grant the owner role, nor act on an existing owner's membership. Both halves are enforced together on purpose. Blocking only the grant would let an admin demote or remove every owner while being unable to appoint a replacement, leaving the organization with no owner. Enforced in `assertOwnerAuthority` and applied to the role update, member removal, invite, and invite-revocation paths. Kilo staff are unaffected because `ensureOrganizationAccess` resolves them to `owner`. The role dropdown and invite dialog mirror the server rules: admins can assign admin, member, and billing_manager, and an owner's membership renders read-only for them. --- .../members/InviteMemberDialog.tsx | 7 +- .../members/MemberRoleDropdown.tsx | 18 +++- .../organization-members-router.test.ts | 93 +++++++++++++++---- .../organization-members-router.ts | 61 +++++++++++- 4 files changed, 151 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/organizations/members/InviteMemberDialog.tsx b/apps/web/src/components/organizations/members/InviteMemberDialog.tsx index 4d78412537b..987cf6984ab 100644 --- a/apps/web/src/components/organizations/members/InviteMemberDialog.tsx +++ b/apps/web/src/components/organizations/members/InviteMemberDialog.tsx @@ -34,7 +34,6 @@ import { } from '@/app/api/organizations/hooks'; import { usePostHog } from 'posthog-js/react'; import { getLowerDomainFromEmail } from '@/lib/utils'; -import { canManageOrganization } from '@kilocode/app-shared/organizations'; const emailSchema = z.email(); @@ -46,15 +45,17 @@ const ROLE_LABELS = { } as const; // Business rules for inviting members: -// - Owners, admins and Kilo admins can invite anyone +// - Owners and Kilo admins can invite anyone +// - Admins can invite anyone except an owner // - Billing managers can invite members only // - Members cannot invite anyone const getAvailableInviteRoles = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean ): OrganizationRole[] => { - if (isKiloAdmin || canManageOrganization(currentUserRole)) + if (isKiloAdmin || currentUserRole === 'owner') return ['owner', 'admin', 'member', 'billing_manager']; + if (currentUserRole === 'admin') return ['admin', 'member', 'billing_manager']; if (currentUserRole === 'billing_manager') return ['member']; return []; diff --git a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx index b47f4defd56..1f12ec12696 100644 --- a/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx +++ b/apps/web/src/components/organizations/members/MemberRoleDropdown.tsx @@ -17,7 +17,6 @@ import { } from '@/components/organizations/OrganizationContext'; import { useSession } from 'next-auth/react'; import { toast } from 'sonner'; -import { canManageOrganization } from '@kilocode/app-shared/organizations'; const getRoleBadgeVariant = (role: string) => { switch (role) { @@ -39,12 +38,22 @@ const ROLE_LABELS = { const ASSIGNABLE_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; -// Owners, admins and Kilo staff may assign any role; nobody else may change roles. +/** + * Mirrors the server rules in organization-members-router: owners and Kilo + * staff may assign any role, admins may assign every role except owner, and + * nobody else may change roles. + */ const getAvailableRoles = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean ): OrganizationRole[] => { - return isKiloAdmin || canManageOrganization(currentUserRole) ? [...ASSIGNABLE_ROLES] : []; + if (isKiloAdmin || currentUserRole === 'owner') { + return [...ASSIGNABLE_ROLES]; + } + if (currentUserRole === 'admin') { + return ['admin', 'member', 'billing_manager']; + } + return []; }; type MemberRoleDropdownProps = { @@ -89,7 +98,8 @@ export function MemberRoleDropdown({ }; const availableRoles = getAvailableRoles(currentUserRole, isKiloAdmin); - const canEditRole = availableRoles.length > 0; + // An admin may not act on an owner's membership, so show it as read-only. + const canEditRole = availableRoles.length > 0 && availableRoles.includes(member.role); // Check if this member is the current user by comparing both ID and email const isCurrentUser = diff --git a/apps/web/src/routers/organizations/organization-members-router.test.ts b/apps/web/src/routers/organizations/organization-members-router.test.ts index a3672f3bafe..b638d105693 100644 --- a/apps/web/src/routers/organizations/organization-members-router.test.ts +++ b/apps/web/src/routers/organizations/organization-members-router.test.ts @@ -210,10 +210,10 @@ describe('organizations members trpc router', () => { expect(result).toEqual({ success: true, updated: 'role and limit' }); }); - it('lets an organization admin grant the owner role, since admin is an owner peer', async () => { + it('lets an organization admin grant the admin role', async () => { const targetUser = await insertTestUser({ - google_user_email: `${crypto.randomUUID()}@org-admin-promote.example.com`, - google_user_name: 'Org Admin Promote Target', + google_user_email: `${crypto.randomUUID()}@org-admin-grant-admin.example.com`, + google_user_name: 'Org Admin Grant Admin Target', is_admin: false, }); await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); @@ -223,13 +223,32 @@ describe('organizations members trpc router', () => { const result = await caller.organizations.members.update({ organizationId: testOrganization.id, memberId: targetUser.id, - role: 'owner', + role: 'admin', }); expect(result).toEqual({ success: true, updated: 'role and limit' }); }); - it('lets an organization admin change an existing owner role', async () => { + it('rejects an organization admin granting the owner role', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-promote.example.com`, + google_user_name: 'Org Admin Promote Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); + + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'owner', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + + it('rejects an organization admin changing an existing owner role', async () => { const targetUser = await insertTestUser({ google_user_email: `${crypto.randomUUID()}@org-admin-demote.example.com`, google_user_name: 'Org Admin Demote Target', @@ -239,13 +258,13 @@ describe('organizations members trpc router', () => { const caller = await createCallerForUser(orgAdminUser.id); - const result = await caller.organizations.members.update({ - organizationId: testOrganization.id, - memberId: targetUser.id, - role: 'member', - }); - - expect(result).toEqual({ success: true, updated: 'role and limit' }); + await expect( + caller.organizations.members.update({ + organizationId: testOrganization.id, + memberId: targetUser.id, + role: 'member', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); }); it('should throw FORBIDDEN error when non-owner tries to assign owner role', async () => { @@ -669,13 +688,13 @@ describe('organizations members trpc router', () => { ).rejects.toThrow('You do not have access to this organization'); }); - it('lets an organization admin remove an owner, matching owner authority', async () => { + it('lets an organization admin remove a non-owner member', async () => { const targetUser = await insertTestUser({ - google_user_email: `${crypto.randomUUID()}@org-admin-remove-owner.example.com`, - google_user_name: 'Org Admin Remove Owner Target', + google_user_email: `${crypto.randomUUID()}@org-admin-remove-member.example.com`, + google_user_name: 'Org Admin Remove Member Target', is_admin: false, }); - await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + await addUserToOrganization(testOrganization.id, targetUser.id, 'member'); const caller = await createCallerForUser(orgAdminUser.id); @@ -687,6 +706,24 @@ describe('organizations members trpc router', () => { expect(result).toEqual({ success: true, updated: targetUser.id }); }); + it('rejects an organization admin removing an owner', async () => { + const targetUser = await insertTestUser({ + google_user_email: `${crypto.randomUUID()}@org-admin-remove-owner.example.com`, + google_user_name: 'Org Admin Remove Owner Target', + is_admin: false, + }); + await addUserToOrganization(testOrganization.id, targetUser.id, 'owner'); + + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.remove({ + organizationId: testOrganization.id, + memberId: targetUser.id, + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + it('should reject billing managers removing owners', async () => { const caller = await createCallerForUser(billingManagerUser.id); @@ -738,6 +775,30 @@ describe('organizations members trpc router', () => { expect(result.acceptInviteUrl).toMatch(/^https?:\/\/.+\/users\/accept-invite\/.+$/); }); + it('should allow an organization admin to invite an admin', async () => { + const caller = await createCallerForUser(orgAdminUser.id); + + const result = await caller.organizations.members.invite({ + organizationId: testOrganization.id, + email: `${crypto.randomUUID()}@org-admin-invite-admin.example.com`, + role: 'admin', + }); + + expect(result).toHaveProperty('acceptInviteUrl'); + }); + + it('should reject an organization admin inviting an owner', async () => { + const caller = await createCallerForUser(orgAdminUser.id); + + await expect( + caller.organizations.members.invite({ + organizationId: testOrganization.id, + email: `${crypto.randomUUID()}@org-admin-invite-owner.example.com`, + role: 'owner', + }) + ).rejects.toThrow('Only an organization owner can manage owners'); + }); + it('should allow system admin to invite any role', async () => { const caller = await createCallerForUser(adminUser.id); diff --git a/apps/web/src/routers/organizations/organization-members-router.ts b/apps/web/src/routers/organizations/organization-members-router.ts index 40ade3d0336..5ba239109d8 100644 --- a/apps/web/src/routers/organizations/organization-members-router.ts +++ b/apps/web/src/routers/organizations/organization-members-router.ts @@ -66,6 +66,29 @@ const SetChildMembershipsSchema = OrganizationIdInputSchema.extend({ childOrganizationIds: z.array(z.uuid()), }); +const OWNER_AUTHORITY_MESSAGE = 'Only an organization owner can manage owners'; + +/** + * Reserves owner authority for owners. Admins match owners everywhere else, but + * may not grant the owner role nor act on an existing owner's membership. Both + * halves are required: allowing only one would let an admin strip every owner + * while being unable to appoint a replacement, leaving the org with no owner. + * + * Kilo staff bypass this because `ensureOrganizationAccess` resolves them to + * `owner`. + */ +function assertOwnerAuthority( + actorRole: OrganizationRole, + target: { currentRole?: OrganizationRole | null; nextRole?: OrganizationRole | null } +): void { + if (actorRole === 'owner') { + return; + } + if (target.currentRole === 'owner' || target.nextRole === 'owner') { + throw new TRPCError({ code: 'FORBIDDEN', message: OWNER_AUTHORITY_MESSAGE }); + } +} + async function getDirectOrganizationRole( organizationId: string, userId: string @@ -131,6 +154,13 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: targetMember?.role, nextRole: role }); + const result = await updateUserRoleInOrganization(organizationId, memberId, role); const updatedUser = await findUserById(memberId); const updatedUserEmail = updatedUser?.google_user_email || 'unknown'; @@ -332,6 +362,13 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: targetMember.role }); + const result = await removeUserFromOrganization(organizationId, memberId, user.id); if (result.rowCount === 0) { throw new TRPCError({ @@ -395,9 +432,15 @@ export const organizationsMembersRouter = createTRPCRouter({ const { organizationId, email, role } = input; // Members can be invited by any billing-capable role; elevated roles - // require organization-management authority. + // require organization-management authority, and only an owner may invite + // another owner. if (role !== 'member') { - await ensureOrganizationAccess(ctx, organizationId, ORGANIZATION_MANAGE_ROLES); + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { nextRole: role }); } // Get organization details @@ -409,8 +452,8 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners, admins and Kilo staff can invite any role. Billing managers can - // invite members only. + // Owners and Kilo staff can invite any role. Admins can invite any role + // except owner. Billing managers can invite members only. let invitation; try { invitation = await inviteUserToOrganization(organizationId, user.id, email, role); @@ -508,7 +551,15 @@ export const organizationsMembersRouter = createTRPCRouter({ }); } - // Owners and admins can revoke any invitation + // Owners can revoke any invitation; admins cannot revoke an owner invite, + // which is a pending grant of owner authority. + const actorRole = await ensureOrganizationAccess( + ctx, + organizationId, + ORGANIZATION_MANAGE_ROLES + ); + assertOwnerAuthority(actorRole, { currentRole: invitation.role }); + // Expire the invitation by setting expires_at to NOW await db .update(organization_invitations) From 261b8e60c08b0a981103431df3cf953bd79346a0 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Tue, 4 Aug 2026 14:43:43 -0500 Subject: [PATCH 3/5] fix(mobile): compile against the admin organization role `apps/mobile` shares `OrganizationRole` with `packages/db`, so adding `admin` broke its typecheck in two places. - `canManageSecurityAgent` took a hand-written narrow role union. It now takes `OrganizationRole` and delegates to `canManageOrganizationBilling`, which already encodes the same owner/billing_manager rule and includes admins. - Mobile's `ROLE_LABEL` is a `Record`, so it needs an `admin` entry; the role is also added to the assignable options an owner sees. --- apps/mobile/src/components/organization/member-row.tsx | 3 ++- packages/app-shared/src/security-agent/settings.test.ts | 1 + packages/app-shared/src/security-agent/settings.ts | 9 ++++----- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/components/organization/member-row.tsx b/apps/mobile/src/components/organization/member-row.tsx index 2a1fb449b0e..49bfe5ffb04 100644 --- a/apps/mobile/src/components/organization/member-row.tsx +++ b/apps/mobile/src/components/organization/member-row.tsx @@ -22,11 +22,12 @@ type MemberRowProps = { export const ROLE_LABEL: Record = { owner: 'Owner', + admin: 'Admin', member: 'Member', billing_manager: 'Billing manager', }; -const ROLE_OPTIONS: OrgRole[] = ['owner', 'member', 'billing_manager']; +const ROLE_OPTIONS: OrgRole[] = ['owner', 'admin', 'member', 'billing_manager']; export function MemberRow({ member, diff --git a/packages/app-shared/src/security-agent/settings.test.ts b/packages/app-shared/src/security-agent/settings.test.ts index b65975d51e9..8b3b32dd04b 100644 --- a/packages/app-shared/src/security-agent/settings.test.ts +++ b/packages/app-shared/src/security-agent/settings.test.ts @@ -24,6 +24,7 @@ describe('Security Agent helpers', () => { it('allows only personal, owner, and billing manager policy changes', () => { expect(canManageSecurityAgent('personal', undefined)).toBe(true); expect(canManageSecurityAgent('org_123', 'owner')).toBe(true); + expect(canManageSecurityAgent('org_123', 'admin')).toBe(true); expect(canManageSecurityAgent('org_123', 'billing_manager')).toBe(true); expect(canManageSecurityAgent('org_123', 'member')).toBe(false); }); diff --git a/packages/app-shared/src/security-agent/settings.ts b/packages/app-shared/src/security-agent/settings.ts index a2910eedd30..bd01ddc1ede 100644 --- a/packages/app-shared/src/security-agent/settings.ts +++ b/packages/app-shared/src/security-agent/settings.ts @@ -1,14 +1,13 @@ +import { canManageOrganizationBilling, type OrganizationRole } from '../organizations/roles'; + export const PERSONAL_SECURITY_SCOPE = 'personal'; export function isPersonalSecurityScope(scope: string): boolean { return scope === PERSONAL_SECURITY_SCOPE; } -export function canManageSecurityAgent( - scope: string, - role: 'owner' | 'billing_manager' | 'member' | undefined -): boolean { - return isPersonalSecurityScope(scope) || role === 'owner' || role === 'billing_manager'; +export function canManageSecurityAgent(scope: string, role: OrganizationRole | undefined): boolean { + return isPersonalSecurityScope(scope) || canManageOrganizationBilling(role); } export function getSecurityAgentAuditUrl(webBaseUrl: string, scope: string): string { From 4e2f5658b73e8ff7156b7c8d3a1178cd2617a20f Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 5 Aug 2026 10:08:32 -0500 Subject: [PATCH 4/5] fix(web): stop offering owner-only member actions to admins Addresses PR review on the member card and stale role docs. `canManageMembers` was widened to admins, which also widened the remove and revoke-invitation gates. The server rejects both for admins when the target is an owner, so an admin saw enabled controls that always failed with FORBIDDEN. - Add `canManageOrganizationOwners` to the shared role module as the single statement of the owner-management rule, mirroring `assertOwnerAuthority`. - Gate `DeleteMemberButton` and `DeleteInvitationButton` on the target's role so admins are not offered actions against an owner or a pending owner invite. - Refresh the member-removal business-rule comment, which predated the role. - Correct the `ORGANIZATION_MANAGE_ROLES` docstring, which still claimed admin was an exact peer of owner, and a stale security-agent test name. --- .../organizations/OrganizationMembersCard.tsx | 33 +++++++++++++++---- .../src/organizations/roles.test.ts | 22 +++++++++++-- .../app-shared/src/organizations/roles.ts | 22 ++++++++++--- .../src/security-agent/settings.test.ts | 2 +- 4 files changed, 65 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 069e5f0202c..5aae3c3fa22 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -45,7 +45,10 @@ import { toast } from 'sonner'; import { useOrganizationReadOnly } from '@/lib/organizations/use-organization-read-only'; import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/lib/trpc/utils'; -import { canManageOrganization } from '@kilocode/app-shared/organizations'; +import { + canManageOrganization, + canManageOrganizationOwners, +} from '@kilocode/app-shared/organizations'; const formatDate = (dateString: string) => { return new Date(dateString).toLocaleDateString('en-US', { @@ -83,14 +86,27 @@ const canManageMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean const canInviteMembers = (role: OrganizationRole, isKiloAdmin: boolean): boolean => canManageMembers(role, isKiloAdmin) || role === 'billing_manager'; +/** + * True when the viewer may act on `targetRole`'s membership or invitation. + * Owner management is reserved for owners, so an admin must not be offered + * controls the server rejects with FORBIDDEN. + */ +const canActOnMemberRole = ( + currentUserRole: OrganizationRole, + isKiloAdmin: boolean, + targetRole: OrganizationRole +): boolean => isKiloAdmin || targetRole !== 'owner' || canManageOrganizationOwners(currentUserRole); + // Business rules for member removal: // - Kilo admins can remove anyone // - Owners can remove anyone except themselves -// - Members cannot remove anyone +// - Admins can remove anyone except themselves and owners +// - Billing managers and members cannot remove anyone const canRemoveMember = ( currentUserRole: OrganizationRole, isKiloAdmin: boolean, - isCurrentUser: boolean + isCurrentUser: boolean, + targetRole: OrganizationRole ): boolean => { // Kilo admin can remove anyone including themselves if (isKiloAdmin) return true; @@ -98,9 +114,11 @@ const canRemoveMember = ( // Cannot remove yourself if (isCurrentUser) return false; + if (!canActOnMemberRole(currentUserRole, isKiloAdmin, targetRole)) return false; + if (canManageMembers(currentUserRole, isKiloAdmin)) return true; - // Members cannot remove anyone + // Billing managers and members cannot remove anyone return false; }; @@ -122,7 +140,7 @@ function DeleteMemberButton({ organizationId, member }: DeleteMemberButtonProps) (member.status === 'active' && member.id === kiloUserId) || member.email === kiloUserEmail; // Only show delete button for active members and if user has permission to remove - const canRemove = canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser); + const canRemove = canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser, member.role); if (member.status !== 'active' || !canRemove) { return null; @@ -160,7 +178,10 @@ function DeleteInvitationButton({ organizationId, member }: DeleteInvitationButt return null; } - const canDelete = canManageMembers(currentUserRole, isKiloAdmin); + // An admin cannot revoke a pending owner invitation, so don't offer the control. + const canDelete = + canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role); if (!canDelete) { return null; diff --git a/packages/app-shared/src/organizations/roles.test.ts b/packages/app-shared/src/organizations/roles.test.ts index 36396668229..9a635900f6e 100644 --- a/packages/app-shared/src/organizations/roles.test.ts +++ b/packages/app-shared/src/organizations/roles.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { canManageOrganization, canManageOrganizationBilling, ORGANIZATION_ROLES } from './roles'; +import { + canManageOrganization, + canManageOrganizationBilling, + canManageOrganizationOwners, + ORGANIZATION_ROLES, +} from './roles'; describe('ORGANIZATION_ROLES', () => { it('is exactly owner, admin, member, billing_manager', () => { @@ -22,8 +27,21 @@ describe('canManageOrganizationBilling', () => { }); }); +describe('canManageOrganizationOwners', () => { + it('is true only for owner', () => { + expect(canManageOrganizationOwners('owner')).toBe(true); + }); + + it('excludes admin so admins cannot appoint or remove owners', () => { + expect(canManageOrganizationOwners('admin')).toBe(false); + expect(canManageOrganizationOwners('billing_manager')).toBe(false); + expect(canManageOrganizationOwners('member')).toBe(false); + expect(canManageOrganizationOwners(undefined)).toBe(false); + }); +}); + describe('canManageOrganization', () => { - it('treats admin as an exact peer of owner', () => { + it('includes owner and admin', () => { expect(canManageOrganization('owner')).toBe(true); expect(canManageOrganization('admin')).toBe(true); }); diff --git a/packages/app-shared/src/organizations/roles.ts b/packages/app-shared/src/organizations/roles.ts index ff5db3b4dd8..1cd489004a3 100644 --- a/packages/app-shared/src/organizations/roles.ts +++ b/packages/app-shared/src/organizations/roles.ts @@ -1,20 +1,19 @@ // Canonical organization role values. Matches apps/web's // OrganizationRoleSchema (lib/organizations/organization-types.ts, a // z.enum(ORGANIZATION_ROLES)) and packages/db's OrganizationRole type. -// Mobile's local string-union copies (e.g. lib/security-agent.ts) do not yet -// include 'admin'; mobile support for the admin role is tracked separately. export const ORGANIZATION_ROLES = ['owner', 'admin', 'member', 'billing_manager'] as const; export type OrganizationRole = (typeof ORGANIZATION_ROLES)[number]; /** - * Roles with full organization-management authority. `admin` is an exact peer - * of `owner`: there is no action an owner can take that an admin cannot. + * Roles with organization-management authority. `admin` matches `owner` + * everywhere except owner management, which {@link canManageOrganizationOwners} + * reserves for owners. */ export const ORGANIZATION_MANAGE_ROLES = ['owner', 'admin'] satisfies OrganizationRole[]; /** * Roles that may manage organization billing. Admins are included because they - * hold the same authority as owners. + * hold the same authority as owners here. */ export const ORGANIZATION_BILLING_ROLES = [ 'owner', @@ -26,6 +25,19 @@ export function canManageOrganization(role: string | undefined): boolean { return ORGANIZATION_MANAGE_ROLES.some(allowed => allowed === role); } +/** + * Owner management — granting the owner role, or acting on an existing owner's + * membership or pending owner invitation — is reserved for owners. Admins are + * excluded so they can neither appoint an owner nor strip the last one. + * + * Mirrors `assertOwnerAuthority` in + * apps/web/src/routers/organizations/organization-members-router.ts; keep the + * two in sync so the UI does not offer actions the server rejects. + */ +export function canManageOrganizationOwners(role: string | undefined): boolean { + return role === 'owner'; +} + /** * True for roles that may manage organization billing (owner, admin or * billing_manager). Ported from web's `canManageBilling` diff --git a/packages/app-shared/src/security-agent/settings.test.ts b/packages/app-shared/src/security-agent/settings.test.ts index 8b3b32dd04b..67b4a0f0d8c 100644 --- a/packages/app-shared/src/security-agent/settings.test.ts +++ b/packages/app-shared/src/security-agent/settings.test.ts @@ -21,7 +21,7 @@ describe('Security Agent helpers', () => { ); }); - it('allows only personal, owner, and billing manager policy changes', () => { + it('allows only personal, owner, admin, and billing manager policy changes', () => { expect(canManageSecurityAgent('personal', undefined)).toBe(true); expect(canManageSecurityAgent('org_123', 'owner')).toBe(true); expect(canManageSecurityAgent('org_123', 'admin')).toBe(true); From 3ec61121ea4e0c9b4d5a9355fcdcf1b43aa91bb9 Mon Sep 17 00:00:00 2001 From: John Fawcett Date: Wed, 5 Aug 2026 10:20:15 -0500 Subject: [PATCH 5/5] fix(web): gate the member row's owner actions for admins Completes the previous commit. `canRemoveMember` gained a target-role parameter but the member row's own `canDelete` still called it with three arguments, which broke the build, and two sibling gates on that row had the same owner-management gap. - Pass the target role at the row call site. - Gate the row's `canEditRole` and the invited-member `canDelete` on `canActOnMemberRole`, so admins are not shown edit mode or a revoke control for an owner or a pending owner invitation. - Leave `canEditLimit` ungated and say why: the server scopes `assertOwnerAuthority` to role changes, so an admin may still set an owner's daily usage limit. --- .../components/organizations/OrganizationMembersCard.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/organizations/OrganizationMembersCard.tsx b/apps/web/src/components/organizations/OrganizationMembersCard.tsx index 5aae3c3fa22..dff7d2fde1a 100644 --- a/apps/web/src/components/organizations/OrganizationMembersCard.tsx +++ b/apps/web/src/components/organizations/OrganizationMembersCard.tsx @@ -582,15 +582,20 @@ export function OrganizationAdminMembers({ const canEditRole = member.status === 'active' && canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role) && (!isCurrentUser || isKiloAdmin); + // Usage limits are deliberately not owner-gated: the server only + // reserves role changes and removals for owners, so an admin may + // still set an owner's daily limit. const canEditLimit = organizationData.plan === 'enterprise' && member.status === 'active' && canManageMembers(currentUserRole, isKiloAdmin); const canDelete = member.status === 'active' - ? canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser) - : canManageMembers(currentUserRole, isKiloAdmin); + ? canRemoveMember(currentUserRole, isKiloAdmin, isCurrentUser, member.role) + : canManageMembers(currentUserRole, isKiloAdmin) && + canActOnMemberRole(currentUserRole, isKiloAdmin, member.role); const canEditChildTeams = member.status === 'active' && canInviteMembers(currentUserRole, isKiloAdmin);