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
13 changes: 10 additions & 3 deletions src/providers/kubernetes.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { canonicalResourceRefs, createMetadata, deriveWorkspaceIdentity, labelHa
import { createWorkspaceSecrets, getWorkspaceToken, rotateWorkspaceCredentials, selectGrantedCredentials } from '../auth.js';
import { requireKubernetesEgress, validateGatewayImage, validateImage } from '../policy.js';
import { grantedEgressPolicy } from '../egress-domains.js';
import { parseAuthCanIList, permissionsNeedingProbe } from './rbac-listing.js';
import { waitForHttpHealth } from '../health.js';
import { KUBERNETES_TOKEN_FILE, KUBERNETES_TOKEN_MOUNT_PATH, PROVIDER_MODEL_AUTH_FILE, runtimeCommand, runtimeEnvironment } from '../runtime-command.js';
import { cleanupTransaction, createTransaction } from '../lifecycle.js';
Expand Down Expand Up @@ -47,10 +48,16 @@ export function createKubernetesProvider({ policy, sourceDirectory }) {
}
throw new ProviderUnavailableError(`Kubernetes cluster is not reachable: ${cause instanceof Error ? cause.message : String(cause)}`, { provider, code: 'WORKSPACE_PROVIDER_CLUSTER_UNREACHABLE', cause });
});
// Each check is a process spawn and a round trip, and there are two dozen of them:
// run sequentially they dominated the time to display readiness. Every denial is
// Every check is a process spawn, and two dozen of them cost seconds on Windows —
// creating the processes, not the round trips, which is why raising the concurrency
// did not help. One listing answers for all of them at the price of one, and whatever
// it does not clearly settle is still asked about directly below. Every denial is
// collected rather than only the first, so one message names everything to request.
const denied = await mapWithConcurrency(requiredPermissions(policy), RBAC_PROBE_CONCURRENCY, async ([verb, resource]) => {
const listing = await kubectl(['auth', 'can-i', '--list', '-n', policy.kubernetes.namespace], { timeoutMs: 20_000 })
.then((result) => parseAuthCanIList(result.stdout))
.catch(() => []);
const unsettled = permissionsNeedingProbe(listing, requiredPermissions(policy));
const denied = await mapWithConcurrency(unsettled, RBAC_PROBE_CONCURRENCY, async ([verb, resource]) => {
const { stdout } = await kubectl(['auth', 'can-i', verb, resource, '-n', policy.kubernetes.namespace], { timeoutMs: 20_000 });
return stdout.trim() === 'yes' ? null : `${verb} ${resource}`;
});
Expand Down
51 changes: 51 additions & 0 deletions src/providers/rbac-listing.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/**
* Reading permissions from one `kubectl auth can-i --list` instead of one process per
* permission.
*
* There are twenty-six permissions to establish, and asking about each one separately
* costs about 3.6 seconds on Windows — process creation, not the cluster, and raising the
* concurrency does not help because the spawns are what saturates. The same answer as a
* single listing costs about 250 milliseconds. That difference is the readiness check
* feeling instant rather than looking stuck.
*
* The listing is a table meant for people, so parsing it is the fragile part. It is used
* only to *grant*: a permission the rules clearly cover is settled, and anything else —
* an unfamiliar row, a wildcard shape not handled here, a listing that failed outright —
* falls through to the explicit probe that was always there. A parse that understands
* less is slower, never wrong.
*/

/** One rule from the listing: which resources it names and which verbs it allows. */
export function parseAuthCanIList(output) {
const rules = [];
for (const line of String(output ?? '').split(/\r?\n/)) {
// Three bracketed columns follow the resource: non-resource URLs, resource names,
// verbs. Anchoring on them avoids depending on the column widths, which shift with
// the longest resource name in the table.
const match = /^(\S*)\s+\[([^\]]*)\]\s+\[([^\]]*)\]\s+\[([^\]]*)\]\s*$/.exec(line);
if (!match) continue;
const [, resourceColumn, , resourceNames, verbColumn] = match;
// A rule limited to named objects does not grant the verb on the resource in general,
// and a row with no resource is about a URL path rather than an API resource.
if (!resourceColumn || resourceNames.trim()) continue;
const verbs = verbColumn.split(/\s+/).filter(Boolean);
if (verbs.length === 0) continue;
rules.push({ resources: resourceColumn.split(',').filter(Boolean), verbs });
}
return rules;
}

function ruleGrants(rule, verb, resource) {
if (!rule.verbs.includes('*') && !rule.verbs.includes(verb)) return false;
return rule.resources.some((candidate) => candidate === '*.*' || candidate === '*' || candidate === resource);
}

/** Whether the listing settles this permission. Unsure is reported as not granted. */
export function listingGrants(rules, verb, resource) {
return rules.some((rule) => ruleGrants(rule, verb, resource));
}

/** The permissions the listing could not settle, which still need asking about directly. */
export function permissionsNeedingProbe(rules, required) {
return required.filter(([verb, resource]) => !listingGrants(rules, verb, resource));
}
81 changes: 81 additions & 0 deletions src/providers/rbac-listing.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { listingGrants, parseAuthCanIList, permissionsNeedingProbe } from './rbac-listing.js';

/** Real output from an admin account, whose single wildcard rule covers everything. */
const ADMIN = [
'Resources Non-Resource URLs Resource Names Verbs',
'*.* [] [] [*]',
' [*] [] [*]',
'selfsubjectreviews.authentication.k8s.io [] [] [create]',
' [/healthz] [] [get]',
].join('\n');

/** The shape a namespace-scoped role produces: named resources, explicit verb lists. */
const SCOPED = [
'Resources Non-Resource URLs Resource Names Verbs',
'pods [] [] [create get delete watch]',
'pods/exec [] [] [create]',
'secrets [] [] [create get delete update]',
'deployments.apps [] [] [create get delete list]',
'services [] [] [create get delete]',
].join('\n');

const REQUIRED = [
['create', 'pods'],
['delete', 'pods'],
['watch', 'pods'],
['create', 'pods/exec'],
['update', 'secrets'],
['list', 'deployments.apps'],
];

describe('permissions read from a single listing', () => {
it('reads a wildcard rule as covering every permission asked about', () => {
const rules = parseAuthCanIList(ADMIN);
expect(permissionsNeedingProbe(rules, REQUIRED)).toEqual([]);
});

it('reads named resources and their verbs', () => {
const rules = parseAuthCanIList(SCOPED);
expect(listingGrants(rules, 'create', 'pods')).toBe(true);
expect(listingGrants(rules, 'watch', 'pods')).toBe(true);
expect(listingGrants(rules, 'create', 'pods/exec')).toBe(true);
expect(listingGrants(rules, 'update', 'secrets')).toBe(true);
expect(listingGrants(rules, 'list', 'deployments.apps')).toBe(true);
});

it('leaves anything the listing does not cover to be asked about directly', () => {
const rules = parseAuthCanIList(SCOPED);
// Absent from the listing entirely, and a verb the listed rule does not carry.
expect(permissionsNeedingProbe(rules, [
['create', 'networkpolicies.networking.k8s.io'],
['list', 'pods'],
])).toEqual([
['create', 'networkpolicies.networking.k8s.io'],
['list', 'pods'],
]);
});

it('ignores rules about URL paths rather than API resources', () => {
// `[/healthz] [] [get]` grants `get` on a path; reading it as a resource rule would
// hand out `get` on whatever happened to be asked about.
const rules = parseAuthCanIList(ADMIN.split('\n').filter((line) => !line.startsWith('*.*')).join('\n'));
expect(listingGrants(rules, 'get', 'pods')).toBe(false);
});

it('does not treat a rule limited to named objects as a rule about the resource', () => {
const listing = [
'Resources Non-Resource URLs Resource Names Verbs',
'secrets [] [one-secret] [get]',
].join('\n');
expect(listingGrants(parseAuthCanIList(listing), 'get', 'secrets')).toBe(false);
});

it('treats an unreadable listing as settling nothing', () => {
// A failed call passes an empty listing, and an unfamiliar table must behave the same
// way: everything falls through to the probe rather than being assumed granted.
expect(permissionsNeedingProbe([], REQUIRED)).toEqual(REQUIRED);
expect(permissionsNeedingProbe(parseAuthCanIList('error: you must be logged in'), REQUIRED)).toEqual(REQUIRED);
expect(parseAuthCanIList(undefined)).toEqual([]);
});
});
Loading