Skip to content
Open
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
6 changes: 3 additions & 3 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `INTERNAL_API_SECRET` - Shared secret for internal API calls between services; used in `apps/web/src/lib/kiloclaw/cli-runs.test.ts`, `kiloclaw-router.test.ts`, dev seed scripts, and other service routers. `[SECRET]`
- `SUPPORT_API_SECRET` - Shared bearer token for Customer Support Automation (CSA) internal API calls. Cloud uses it to authorize CSA → Cloud `apps/web/src/app/api/internal/support/` and Cloud → CSA `POST /api/internal/cloud/users/gdpr-scrub`. A CSA compromise can also call Cloud deletion and Cloud can scrub CSA-local PII. Leak can look up any email and enqueue deletion for non-admin, non-bot, non-live-subscription customers; access disable is deferred to worker preflight and pending requests can be cancelled. Keep production values off preview deployments; rotate Cloud and CSA together. `[SECRET]`
- `BOUNDED_INTERNAL_SERVICE_TOKENS_ENABLED` - Set to exact `true` to enable modern, purpose-labelled internal assertions at the Phase 5.1 bounded Git broker, export, deletion, and Session Ingest callsites. Unset or any other value retains their existing legacy token formats. Enable only after compatible readers, including the dedicated GitHub disconnect audience, are deployed; generic human/control/runtime signers are not affected. [SERVER]
- `NATIVE_RESOURCE_TOKENS_ENABLED` - Set to exact `true` to permit fresh native adoption of separate one-hour API/gateway access tokens only for clients explicitly requesting `api-gateway-v1`, provided `SHARED_RESOURCE_TOKENS_ENABLED` is also exact `true`. Default-off. Unsupported clients keep legacy responses. Turning this flag off affects subsequent native issuance/refreshes only; active modern device credentials can continue receiving bounded control tokens while their owned device session and current user pepper remain valid. [SERVER]
- `SHARED_RESOURCE_TOKENS_ENABLED` - Master default-off readiness gate. Fresh modern producer issuance requires both this flag and its family flag below to be exact `true`; unset or any other value disables adoption. Native adoption separately requires `NATIVE_RESOURCE_TOKENS_ENABLED` and this master, independent of producer families; old CLI negotiation is unchanged. These flags do not revoke existing credentials. Valid modern device access credentials retain bounded control issuance after rollback, with current owned-session, pepper, and requested organization membership validation and a one-hour/parent-expiry cap. Chat likewise retains bounded three-audience issuance for validated modern devices; modern credentials never fall back to broad legacy tokens. Persisted modern workload renewal does not use adoption gates. Separately deployed readers/producers must be verified before activation. [SERVER]
- `NATIVE_RESOURCE_TOKENS_ENABLED` - Set to exact `true` to permit fresh native adoption of separate one-hour API/gateway access tokens only for clients explicitly requesting `api-gateway-v1`, provided `SHARED_RESOURCE_TOKENS_ENABLED` is also exact `true`. Default-off. Unsupported clients keep legacy responses. Turning this flag off affects subsequent native issuance/refreshes only; active modern device credentials can continue receiving bounded Cloud Agent/Wasteland control tokens while their owned device session and current user pepper remain valid. Gastown still requires its adoption gate. [SERVER]
- `SHARED_RESOURCE_TOKENS_ENABLED` - Master default-off readiness gate. Fresh modern producer issuance requires both this flag and its family flag below to be exact `true`; unset or any other value disables adoption. Native adoption separately requires `NATIVE_RESOURCE_TOKENS_ENABLED` and this master, independent of producer families; old CLI negotiation is unchanged. These flags do not revoke existing credentials. Valid modern device access credentials retain bounded Cloud Agent/Wasteland control issuance after rollback, with current owned-session, pepper, and requested organization membership validation and a one-hour/parent-expiry cap. Chat likewise retains bounded three-audience issuance for validated modern devices; modern credentials never fall back to broad legacy tokens. Persisted modern workload renewal does not use adoption gates. Separately deployed readers/producers must be verified before activation. [SERVER]
- `CLOUD_AGENT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Cloud Agent Next request control and workflow control tokens; requires the master and exact `true`. [SERVER]
- `GASTOWN_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Gastown control tokens; requires the master and exact `true`. This is not a safe-activation declaration: known ingest-audience and live-token-transport blockers remain deferred. [SERVER]
- `GASTOWN_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Gastown control tokens; requires the master and exact `true`. This is not a safe-activation declaration: known ingest-audience, organization-bound admission, and live-token-transport blockers remain deferred. Disabled Gastown issuance also rejects modern device control requests; it has no device rollback exception. [SERVER]
- `WASTELAND_RESOURCE_TOKENS_ENABLED` - Default-off family gate for Wasteland control tokens; requires the master and exact `true`. [SERVER]
- `CHAT_RESOURCE_TOKENS_ENABLED` - Default-off family gate for fresh chat resource issuance; requires the master and exact `true`. Validated modern devices retain chat/event-service/notifications issuance after rollback, capped by one hour and parent expiry. [SERVER]
- `DELEGATED_RESOURCE_TOKENS_ENABLED` - Default-off family gate for explicit API, gateway, attribution, and HTML-deploy delegation, including the organization user-token resource route; requires the master and exact `true`. Disabled explicit delegation remains unavailable. [SERVER]
Expand Down
55 changes: 30 additions & 25 deletions apps/web/src/app/api/gastown/token/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user/server';
import { generateApiToken } from '@/lib/tokens';
import {
createControlTokenForRequest,
TypedResourceDelegationError,
} from '@/lib/auth/resource-delegation';
import { isGastownEnabled } from '@/lib/gastown/feature-flags';
import { getUserOrgMemberships } from '@/lib/organizations/organizations';
import { recordKiloAdminElevationForRequest, serviceTarget } from '@/lib/admin/admin-access-log';

const ONE_HOUR_SECONDS = 60 * 60;
Expand All @@ -20,11 +22,11 @@ const ONE_HOUR_SECONDS = 60 * 60;
*
* Access is controlled by the `gastown-access` PostHog feature flag.
* The JWT includes `gastownAccess`, `isAdmin`, `apiTokenPepper`, and
* `orgMemberships` so the worker can enforce access and check org
* membership without DB round-trips.
* `orgMemberships`; the worker also checks current account and organization
* authorization against the primary database.
*/
export async function POST() {
const { user, authFailedResponse, tokenSource } = await getUserFromAuth({ adminOnly: false });
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) return authFailedResponse;
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

Expand All @@ -34,26 +36,29 @@ export async function POST() {
return NextResponse.json({ error: 'Gastown access denied' }, { status: 403 });
}

if (user.is_admin) {
// The minted token carries `isAdmin`, so the elevation is exercised inside
// the Gastown worker where this app emits nothing. Correlate on
// `kiloUserId` within the token's lifetime below.
await recordKiloAdminElevationForRequest({
user,
tokenSource,
reason: 'service_token_mint',
target: serviceTarget('gastown'),
try {
const result = await createControlTokenForRequest(user, 'gastown', {
Comment thread
pandemicsyn marked this conversation as resolved.
tokenSource: 'gastown',
expiresIn: 55 * 60,
legacyExpiresIn: ONE_HOUR_SECONDS,
extra: { isAdmin: user.is_admin, gastownAccess: true },
});
if (result.user.is_admin) {
await recordKiloAdminElevationForRequest({
user: result.user,
tokenSource: result.tokenSource,
reason: 'service_token_mint',
target: serviceTarget('gastown'),
});
}
return NextResponse.json({ token: result.token, expiresAt: result.expiresAt });
} catch (error) {
if (error instanceof TypedResourceDelegationError) {
return NextResponse.json(
{ error: error.message, code: error.delegationCode },
{ status: error.status }
);
}
throw error;
}

const orgMemberships = await getUserOrgMemberships(user.id);

const token = generateApiToken(
user,
{ isAdmin: user.is_admin, gastownAccess: true, orgMemberships },
{ expiresIn: ONE_HOUR_SECONDS }
);
const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString();

return NextResponse.json({ token, expiresAt });
}
51 changes: 28 additions & 23 deletions apps/web/src/app/api/wasteland/token/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import 'server-only';
import { NextResponse } from 'next/server';
import { getUserFromAuth } from '@/lib/user/server';
import { generateApiToken } from '@/lib/tokens';
import { getUserOrgMemberships } from '@/lib/organizations/organizations';
import {
createControlTokenForRequest,
TypedResourceDelegationError,
} from '@/lib/auth/resource-delegation';
import { recordKiloAdminElevationForRequest, serviceTarget } from '@/lib/admin/admin-access-log';

const ONE_HOUR_SECONDS = 60 * 60;
Expand All @@ -21,30 +23,33 @@ const ONE_HOUR_SECONDS = 60 * 60;
* worker can enforce access and check org membership without DB round-trips.
*/
export async function POST() {
const { user, authFailedResponse, tokenSource } = await getUserFromAuth({ adminOnly: false });
const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false });
if (authFailedResponse) return authFailedResponse;
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });

if (user.is_admin) {
// The minted token carries `isAdmin`, so the elevation is exercised inside
// the Wasteland worker where this app emits nothing. Correlate on
// `kiloUserId` within the token's lifetime below.
await recordKiloAdminElevationForRequest({
user,
tokenSource,
reason: 'service_token_mint',
target: serviceTarget('wasteland'),
try {
const result = await createControlTokenForRequest(user, 'wasteland', {
tokenSource: 'wasteland',
expiresIn: ONE_HOUR_SECONDS,
legacyExpiresIn: ONE_HOUR_SECONDS,
extra: { isAdmin: user.is_admin },
});
if (result.user.is_admin) {
await recordKiloAdminElevationForRequest({
user: result.user,
tokenSource: result.tokenSource,
reason: 'service_token_mint',
target: serviceTarget('wasteland'),
});
}
return NextResponse.json({ token: result.token, expiresAt: result.expiresAt });
} catch (error) {
if (error instanceof TypedResourceDelegationError) {
return NextResponse.json(
{ error: error.message, code: error.delegationCode },
{ status: error.status }
);
}
throw error;
}

const orgMemberships = await getUserOrgMemberships(user.id);

const token = generateApiToken(
user,
{ isAdmin: user.is_admin, orgMemberships },
{ expiresIn: ONE_HOUR_SECONDS }
);
const expiresAt = new Date(Date.now() + 55 * 60 * 1000).toISOString();

return NextResponse.json({ token, expiresAt });
}
61 changes: 59 additions & 2 deletions apps/web/src/lib/auth/resource-delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,37 @@ function modernToken(
}

describe('resource delegation authority', () => {
test.each([false, true])(
'excludes deleted organizations from Gastown claims when shared issuance is %s',
async enabled => {
shared.enabled = enabled;
const current = await user();
const active = await organizationFor(current.id);
const deleted = await organizationFor(current.id);
const billing = await organizationFor(current.id);
await db.insert(organization_memberships).values([
{ organization_id: active.id, kilo_user_id: current.id, role: 'member' },
{ organization_id: deleted.id, kilo_user_id: current.id, role: 'owner' },
{ organization_id: billing.id, kilo_user_id: current.id, role: 'billing_manager' },
]);
await db
.update(organizations)
.set({ deleted_at: new Date().toISOString() })
.where(eq(organizations.id, deleted.id));
jest.mocked(getUserFromSessionForCredentialIssuance).mockResolvedValue({
user: current,
authFailedResponse: null,
});

const result = await createControlTokenForRequest(current, 'gastown', {
headers: new Headers(),
});
const claims = jwt.verify(result.token, secret) as jwt.JwtPayload;
expect(claims.orgMemberships).toEqual([{ orgId: active.id, role: 'member' }]);
expect(claims.aud).toBe(enabled ? 'gastown' : undefined);
}
);

test.each([true, false])(
'accepts direct billing_manager membership when shared issuance is %s',
async enabled => {
Expand Down Expand Up @@ -306,10 +337,36 @@ describe('resource delegation authority', () => {
});

shared.enabled = false;
const rollback = await createControlTokenForRequest(current, 'gastown', { headers });
await expect(
createControlTokenForRequest(current, 'gastown', { headers })
).rejects.toMatchObject({
status: 503,
delegationCode: 'MIGRATION_UNAVAILABLE',
});
shared.enabled = true;
shared.family = 'cloud-agent-next';
await expect(
createControlTokenForRequest(current, 'gastown', { headers })
).rejects.toMatchObject({
status: 503,
delegationCode: 'MIGRATION_UNAVAILABLE',
});
shared.family = 'gastown';
const enabledGastown = await createControlTokenForRequest(current, 'gastown', { headers });
expect(jwt.verify(enabledGastown.token, secret)).toMatchObject({
aud: 'gastown',
tokenPurpose: 'device-access',
});
shared.enabled = false;
const wasteland = await createControlTokenForRequest(current, 'wasteland', { headers });
expect(jwt.verify(wasteland.token, secret)).toMatchObject({
aud: 'wasteland',
tokenPurpose: 'device-access',
});
const rollback = await createControlTokenForRequest(current, 'cloud-agent-next', { headers });
const claims = jwt.verify(rollback.token, secret) as jwt.JwtPayload;
expect(claims).toMatchObject({
aud: 'gastown',
aud: 'cloud-agent-next',
tokenPurpose: 'device-access',
credentialExchange: false,
deviceSessionId: session.id,
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/lib/auth/resource-delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,11 @@ async function membershipsFor(userId: string) {
role: organization_memberships.role,
})
.from(organization_memberships)
.innerJoin(organizations, eq(organizations.id, organization_memberships.organization_id))
.where(
and(
eq(organization_memberships.kilo_user_id, userId),
isNull(organizations.deleted_at),
ne(organization_memberships.role, 'billing_manager')
)
);
Expand Down Expand Up @@ -378,7 +380,11 @@ export async function createControlTokenForRequest(
}
if (!isResourceTokenIssuanceEnabled(resource)) {
if (authority.isModern) {
if (authority.credentialKind === 'device-access' && authority.deviceSessionId) {
if (
resource !== 'gastown' &&
authority.credentialKind === 'device-access' &&
authority.deviceSessionId
) {
return await createModernControlToken(authority, resource, options);
}
throw new TypedResourceDelegationError(
Expand Down
20 changes: 10 additions & 10 deletions apps/web/src/lib/wasteland/server-resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import type { User } from '@kilocode/db/schema';
import { createTRPCClient, httpLink } from '@trpc/client';
import type { WrappedWastelandRouter } from '@/lib/wasteland/types/router';
import { WASTELAND_URL } from '@/lib/constants';
import { generateApiToken } from '@/lib/tokens';
import { getUserOrgMemberships } from '@/lib/organizations/organizations';
import { createControlTokenForRequest } from '@/lib/auth/resource-delegation';
import { recordKiloAdminElevationForRequest, serviceTarget } from '@/lib/admin/admin-access-log';
import { parseDolthubUpstream } from '@/lib/wasteland/upstream';

Expand All @@ -28,21 +27,22 @@ export async function resolveWastelandUpstreamForUser(
if (!WASTELAND_URL) return null;

try {
if (user.is_admin) {
const control = await createControlTokenForRequest(user, 'wasteland', {
expiresIn: 60 * 5,
legacyExpiresIn: 60 * 5,
extra: { isAdmin: user.is_admin },
});
const signedUser = control.user;
if (signedUser.is_admin) {
// Same elevation as POST /api/wasteland/token: the minted token carries
// `isAdmin` into the worker, which emits nothing back here.
await recordKiloAdminElevationForRequest({
user,
user: signedUser,
reason: 'service_token_mint',
target: serviceTarget('wasteland'),
});
}
const orgMemberships = await getUserOrgMemberships(user.id);
const token = generateApiToken(
user,
{ isAdmin: user.is_admin, orgMemberships },
{ expiresIn: 60 * 5 }
);
const token = control.token;

const client = createTRPCClient<WrappedWastelandRouter>({
links: [
Expand Down
Loading
Loading