diff --git a/src/providers/kubernetes.js b/src/providers/kubernetes.js index b8550a5..81572b3 100644 --- a/src/providers/kubernetes.js +++ b/src/providers/kubernetes.js @@ -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'; @@ -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}`; }); diff --git a/src/providers/rbac-listing.js b/src/providers/rbac-listing.js new file mode 100644 index 0000000..711c4a1 --- /dev/null +++ b/src/providers/rbac-listing.js @@ -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)); +} diff --git a/src/providers/rbac-listing.test.js b/src/providers/rbac-listing.test.js new file mode 100644 index 0000000..df1591a --- /dev/null +++ b/src/providers/rbac-listing.test.js @@ -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([]); + }); +});