All files / src/entities/runners project-access.ts

100% Statements 45/45
100% Branches 25/25
100% Functions 13/13
100% Lines 40/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 9722x   22x             22x 22x 22x   22x 87x     22x 57x 57x 57x 57x 57x       22x 5x 2x           52x     52x                     22x 45x   22x 26x 18x 17x 15x                 22x     11x 5x   4x 5x             22x       5x 2x 2x 4x   2x 4x   27x   27x   2x    
import { GITLAB_ALLOWED_PROJECT_IDS } from '../../config';
import type { GraphQLClient } from '../../graphql/client';
import {
  RUNNER_PROJECT_ACCESS,
  RUNNERS_PER_ACCESS_QUERY,
  runnerAccessBatch,
  type RunnerProject,
  type RunnerProvenance,
} from '../../graphql/runners';
import { extractSimpleId } from '../../utils/idConversion';
import { mapBounded } from '../../utils/bounded-map';
import { canonicalProjectPath } from '../../utils/api-path';
 
export function runnerProjectsRestricted(): boolean {
  return GITLAB_ALLOWED_PROJECT_IDS.length > 0;
}
 
export function runnerProjectAllowed(project: RunnerProject): boolean {
  const id = extractSimpleId(project.id);
  const path = canonicalProjectPath(project.fullPath).toLowerCase();
  return GITLAB_ALLOWED_PROJECT_IDS.some((value) => {
    const configured = canonicalProjectPath(value);
    return configured === id || configured.toLowerCase() === path;
  });
}
 
export function assertRunnerProjectAllowed(fullPath: string, id: string): void {
  if (runnerProjectsRestricted() && !runnerProjectAllowed({ id, fullPath })) {
    throw new Error(`Project ${fullPath} is not allowed by GITLAB_ALLOWED_PROJECT_IDS`);
  }
}
 
/** Whether a runner's projects confine it to the allowlist. */
function provenanceAllowed(runner: RunnerProvenance | null | undefined): boolean {
  const projects = runner?.projects;
  // Never infer ownership from a truncated or permission-filtered project list.
  // Shared group/instance runners cannot be confined to a project allowlist.
  return (
    runner?.runnerType === 'PROJECT_TYPE' &&
    typeof runner.projectCount === 'number' &&
    runner.projectCount > 0 &&
    projects?.pageInfo?.hasNextPage === false &&
    Array.isArray(projects.nodes) &&
    projects.nodes.length === runner.projectCount &&
    projects.nodes.every(runnerProjectAllowed)
  );
}
 
const runnerGid = (id: string | number): string =>
  `gid://gitlab/Ci::Runner/${extractSimpleId(String(id))}`;
 
export async function assertRunnerAllowed(client: GraphQLClient, id: number): Promise<void> {
  if (!runnerProjectsRestricted()) return;
  const result = await client.request(RUNNER_PROJECT_ACCESS, { id: runnerGid(id) });
  if (!provenanceAllowed(result.runner)) {
    throw new Error(`Runner ${id} is not allowed by GITLAB_ALLOWED_PROJECT_IDS`);
  }
}
 
/**
 * A runner listing that read each runner's projects (its `restricted` variable), kept to the
 * runners the allowlist confines, without those fields. Its cursor stays the one GitLab gave,
 * so filtering does not make later pages unreachable.
 */
export function keepAllowedRunners<T extends RunnerProvenance, C extends { nodes: T[] }>(
  connection: C | null,
): C | null {
  if (!runnerProjectsRestricted() || !connection) return connection;
  const nodes = connection.nodes
    .filter(provenanceAllowed)
    .map(({ projectCount: _count, projects: _projects, ...node }) => node);
  return { ...connection, nodes };
}
 
/**
 * A runner page that carries no projects (the REST listing), kept to the runners the
 * allowlist confines: their projects are read RUNNERS_PER_ACCESS_QUERY runners per query.
 */
export async function filterAllowedRunners<
  T extends { id: string | number },
  C extends { nodes: T[] },
>(client: GraphQLClient, connection: C | null): Promise<C | null> {
  if (!runnerProjectsRestricted() || !connection) return connection;
  const batches: T[][] = [];
  for (let start = 0; start < connection.nodes.length; start += RUNNERS_PER_ACCESS_QUERY)
    batches.push(connection.nodes.slice(start, start + RUNNERS_PER_ACCESS_QUERY));
  // A page of at most 100 runners is at most ten batches, a few in flight at a time.
  const checked = await mapBounded(batches, 5, async (batch) => {
    const result = await client.request(
      runnerAccessBatch(batch.length),
      Object.fromEntries(batch.map((node, index) => [`id${index}`, runnerGid(node.id)])),
    );
    return batch.filter((_node, index) => provenanceAllowed(result[`r${index}`]));
  });
  return { ...connection, nodes: checked.flat() };
}