Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions apps/web/src/app/admin/components/PlatformAdminsContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export function PlatformAdminsContent() {
: rosterError || permissions.isError
? 'Could not determine your permissions. Reload to try again.'
: canManageAdmins
? 'Search registered kilocode.ai users who are not already admins. New admins receive no subordinate permissions.'
? 'Search registered kilocode.ai or anaconda.com users who are not already admins. New admins receive no subordinate permissions.'
: 'Superadmin access is required to grant platform admin access or manage permissions.'}
</CardDescription>
</CardHeader>
Expand All @@ -350,7 +350,8 @@ export function PlatformAdminsContent() {
id="platform-admin-candidate-search-hint"
className="text-muted-foreground text-xs"
>
Only registered kilocode.ai users who are not already admins can be granted access.
Only registered kilocode.ai or anaconda.com users who are not already admins can be
granted access.
</p>
</div>

Expand All @@ -366,7 +367,7 @@ export function PlatformAdminsContent() {
</div>
) : candidateRows.length === 0 && !isSearching ? (
<div className="text-muted-foreground py-4 text-center text-sm">
No eligible kilocode.ai users matched that search.
No eligible kilocode.ai or anaconda.com users matched that search.
</div>
) : (
<div className="rounded-md border">
Expand Down
17 changes: 13 additions & 4 deletions apps/web/src/lib/admin/platform-admin.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { isEligibleForPlatformAdmin, shouldAutoProvisionPlatformAdmin } from './platform-admin';

describe('isEligibleForPlatformAdmin', () => {
it('is eligible for an exact lowercase kilocode.ai email and hosted domain', () => {
expect(isEligibleForPlatformAdmin('person@kilocode.ai', 'kilocode.ai')).toBe(true);
});
test.each(['kilocode.ai', 'anaconda.com'])(
'is eligible for an exact lowercase %s email and hosted domain',
domain => {
expect(isEligibleForPlatformAdmin(`person@${domain}`, domain)).toBe(true);
}
);

it('is not eligible when the hosted domain is a personal/provider placeholder', () => {
expect(isEligibleForPlatformAdmin('person@kilocode.ai', '@@personal@@')).toBe(false);
Expand All @@ -13,10 +16,14 @@ describe('isEligibleForPlatformAdmin', () => {
expect(isEligibleForPlatformAdmin('person@kilocode.ai', null)).toBe(false);
});

it('is not eligible when the hosted domain matches but the email is not a kilocode.ai email', () => {
it('is not eligible when the hosted domain matches but the email is not from an allowed domain', () => {
expect(isEligibleForPlatformAdmin('person@example.com', 'kilocode.ai')).toBe(false);
});

it('is not eligible when the email and hosted domain belong to different allowed domains', () => {
expect(isEligibleForPlatformAdmin('person@anaconda.com', 'kilocode.ai')).toBe(false);
});

it('is not eligible for uppercase email variants', () => {
expect(isEligibleForPlatformAdmin('Person@Kilocode.ai', 'kilocode.ai')).toBe(false);
});
Expand All @@ -27,10 +34,12 @@ describe('isEligibleForPlatformAdmin', () => {

it('is not eligible for a subdomain lookalike', () => {
expect(isEligibleForPlatformAdmin('person@sub.kilocode.ai', 'kilocode.ai')).toBe(false);
expect(isEligibleForPlatformAdmin('person@sub.anaconda.com', 'anaconda.com')).toBe(false);
});

it('is not eligible for a registrable-parent-domain lookalike', () => {
expect(isEligibleForPlatformAdmin('person@notkilocode.ai', 'kilocode.ai')).toBe(false);
expect(isEligibleForPlatformAdmin('person@notanaconda.com', 'anaconda.com')).toBe(false);
});

it('is not eligible for a fake-login hosted domain even with a kilocode.ai email', () => {
Expand Down
16 changes: 10 additions & 6 deletions apps/web/src/lib/admin/platform-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ import 'server-only';

import { hosted_domain_specials } from '@/lib/auth/constants';

export const platformAdminDomains = [
hosted_domain_specials.kilocode_admin,
'anaconda.com',
] as const;

/**
* Exact eligibility rule for the Kilo production platform-admin domain.
* Exact eligibility rule for Kilo production platform-admin domains.
*
* This intentionally preserves current case-sensitive behavior: the hosted
* domain must equal `kilocode.ai` exactly and the email must end with
* `@kilocode.ai` exactly. It does not broaden matching to uppercase
* domain must equal an allowed corporate domain exactly and the email must
* end with that same domain exactly. It does not broaden matching to uppercase
* variants, subdomains, or registrable parent domains.
*
* Used both to gate the production auto-provisioning rule (historically)
Expand All @@ -17,9 +22,8 @@ import { hosted_domain_specials } from '@/lib/auth/constants';
* against freshly loaded rows.
*/
export function isEligibleForPlatformAdmin(email: string, hostedDomain: string | null): boolean {
return (
hostedDomain === hosted_domain_specials.kilocode_admin &&
email.endsWith('@' + hosted_domain_specials.kilocode_admin)
return platformAdminDomains.some(
domain => hostedDomain === domain && email.endsWith(`@${domain}`)
);
}

Expand Down
40 changes: 38 additions & 2 deletions apps/web/src/routers/admin-platform-admins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createCallerForUser } from '@/routers/test-utils';
import { hosted_domain_specials } from '@/lib/auth/constants';

const KILO_DOMAIN = hosted_domain_specials.kilocode_admin;
const ANACONDA_DOMAIN = 'anaconda.com';

async function insertQualifyingAdmin(overrides: Parameters<typeof insertTestUser>[0] = {}) {
return insertTestUser({
Expand Down Expand Up @@ -37,6 +38,17 @@ async function insertEligibleCandidate(overrides: Parameters<typeof insertTestUs
});
}

async function insertAnacondaEligibleCandidate(
overrides: Parameters<typeof insertTestUser>[0] = {}
) {
return insertTestUser({
google_user_email: `candidate-${crypto.randomUUID()}@anaconda.com`,
hosted_domain: ANACONDA_DOMAIN,
is_admin: false,
...overrides,
});
}

async function getUserAdminNotes(userId: string) {
return db.query.user_admin_notes.findMany({
where: eq(user_admin_notes.kilo_user_id, userId),
Expand Down Expand Up @@ -111,13 +123,16 @@ describe('admin.users.searchPlatformAdminCandidates', () => {
).rejects.toMatchObject({ code: 'FORBIDDEN' });
});

test('returns only non-admin users satisfying both exact Kilo eligibility rules', async () => {
test('returns only non-admin users satisfying an exact allowed-domain email and hosted-domain pair', async () => {
const searchToken = crypto.randomUUID();
const admin = await insertQualifyingAdmin();

const eligible = await insertEligibleCandidate({
google_user_email: `${searchToken}@kilocode.ai`,
});
const anacondaEligible = await insertAnacondaEligibleCandidate({
google_user_email: `${searchToken}@anaconda.com`,
});
const alreadyAdmin = await insertTestUser({
google_user_email: `${searchToken}-already-admin@kilocode.ai`,
hosted_domain: KILO_DOMAIN,
Expand All @@ -138,6 +153,11 @@ describe('admin.users.searchPlatformAdminCandidates', () => {
hosted_domain: KILO_DOMAIN,
is_admin: false,
});
const mismatchedAllowedDomainUser = await insertTestUser({
google_user_email: `${searchToken}-mismatch@anaconda.com`,
hosted_domain: KILO_DOMAIN,
is_admin: false,
});
const uppercaseEmailUser = await insertTestUser({
google_user_email: `${searchToken.toUpperCase()}@Kilocode.ai`,
hosted_domain: KILO_DOMAIN,
Expand All @@ -160,11 +180,13 @@ describe('admin.users.searchPlatformAdminCandidates', () => {
});

const resultIds = results.map(user => user.id);
expect(resultIds).toEqual([eligible.id]);
expect(resultIds).toEqual(expect.arrayContaining([eligible.id, anacondaEligible.id]));
expect(resultIds).toHaveLength(2);
expect(resultIds).not.toContain(alreadyAdmin.id);
expect(resultIds).not.toContain(fakeLoginUser.id);
expect(resultIds).not.toContain(wrongDomainUser.id);
expect(resultIds).not.toContain(wrongEmailUser.id);
expect(resultIds).not.toContain(mismatchedAllowedDomainUser.id);
expect(resultIds).not.toContain(uppercaseEmailUser.id);
expect(resultIds).not.toContain(subdomainUser.id);
expect(resultIds).not.toContain(lookalikeDomainUser.id);
Expand Down Expand Up @@ -228,6 +250,20 @@ describe('admin.users.setPlatformAdminAccess — grant', () => {
expect(notes[0]?.admin_kilo_user_id).toBe(admin.id);
});

test('a superadmin can grant an eligible anaconda.com target', async () => {
const admin = await insertQualifyingAdmin();
const target = await insertAnacondaEligibleCandidate();
const caller = await createCallerForUser(admin.id);

const result = await caller.admin.users.setPlatformAdminAccess({
userId: target.id,
isAdmin: true,
});

expect(result).toMatchObject({ changed: true, user: { id: target.id, is_admin: true } });
expect((await getUser(target.id)).is_admin).toBe(true);
});

test('rejects granting an ineligible target even if submitted directly', async () => {
const admin = await insertQualifyingAdmin();
const ineligibleTarget = await insertTestUser({
Expand Down
18 changes: 12 additions & 6 deletions apps/web/src/routers/admin-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import {
} from '@/lib/trpc/init';
import { userCanViewSessions, userIsSuperadmin } from '@/lib/admin/admin-permissions';
import { userCanManageCredits } from '@/lib/admin/credit-management';
import { isEligibleForPlatformAdmin } from '@/lib/admin/platform-admin';
import { hosted_domain_specials } from '@/lib/auth/constants';
import { isEligibleForPlatformAdmin, platformAdminDomains } from '@/lib/admin/platform-admin';
import { db, type DrizzleTransaction } from '@/lib/drizzle';
import { insertKiloClawSubscriptionChangeLog, type KiloClawSubscription } from '@kilocode/db';
import {
Expand Down Expand Up @@ -1590,8 +1589,9 @@ export const adminRouter = createTRPCRouter({
};
}),

// Server-filtered so results only ever contain non-admin, exact-eligibility
// kilocode.ai users. Filtering here is a UX convenience, not a security
// Server-filtered so results only ever contain non-admin users matching an
// exact platform-admin email and hosted-domain pair. Filtering here is a UX
// convenience, not a security
// boundary: setPlatformAdminAccess independently re-validates eligibility
// against freshly locked rows before granting.
searchPlatformAdminCandidates: superadminProcedure
Expand All @@ -1605,10 +1605,16 @@ export const adminRouter = createTRPCRouter({
.where(
and(
eq(kilocode_users.is_admin, false),
eq(kilocode_users.hosted_domain, hosted_domain_specials.kilocode_admin),
// Case-sensitive suffix match — `like` (not `ilike`) so search
// eligibility cannot disagree with isEligibleForPlatformAdmin.
like(kilocode_users.google_user_email, `%@${hosted_domain_specials.kilocode_admin}`),
or(
...platformAdminDomains.map(domain =>
and(
eq(kilocode_users.hosted_domain, domain),
like(kilocode_users.google_user_email, `%@${domain}`)
)
)
),
or(
ilike(kilocode_users.google_user_email, `%${escaped}%`),
ilike(kilocode_users.google_user_name, `%${escaped}%`),
Expand Down