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 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 | 49x 49x 346x 415x 346x 277x 49x 481x 481x 480x 365x 407x 398x 89x 382x 4x 1974x 49x 49x 114x 114x 107x 23x 20x 1x 19x 12x 4x 84x 3x 81x 49x 8x 10x 9x 2x 7x 3x 4x 5x 4x 164x 13x 49x 45x 6x 6x 49x 110x 114x 5x 5x 3x 109x 1x 108x 4x 4x 3x 104x 49x 101x 64x 18x 49x 14x 49x 49x 26x 92x 92x 49x 21x 49x 21x 21x 49x 17x 14x 26x 8x 3x 5x 2x 5x 3x 2x 2x 4x 2x 5x 6x 3x 5x 3x 2x 49x 100x 100x 100x 86x 2249x 2125x 124x 124x 49x 49x 28x 2x 2x 3x 27x 3x 191x 49x 41x 49x 120x 49x 42x 49x 151x 150x 54x 2249x 2249x 54x 96x 76x 1530x 1530x 4x 4x 1x 20x 17x 1020x 1020x 3x | /**
* Listing and search calls that name no project or group read across everything the
* account can see. Under a working scope they are narrowed to it: the scope becomes the
* call's own filter where the tool has one, and otherwise the results are filtered.
*/
import type { ScopeConfig } from '../profiles/types';
import { decodePath, type ScopeEnforcer } from '../profiles/scope-enforcer';
type Args = Record<string, unknown>;
/** One group (with or without its subgroups) or one project. */
export type Target = { group: string; subgroups: boolean } | { project: string };
/**
* The scope as one group or one project, when it is exactly that, however a preset names it:
* the single field, a list of one, or a namespace. A namespace always covers its subgroups.
*/
export function singleTarget(scope: ScopeConfig): Target | undefined {
let found: Target | undefined;
for (const target of scopeTargets(scope)) {
if (found) return undefined;
found = target;
}
return found;
}
/**
* Iterate targets by reference, allowing callers to stop without copying a wide preset. A
* preset may name a place URL-encoded, as tools accept it; targets carry the plain path,
* which tools encode themselves and listed paths are compared with.
*/
export function* scopeTargets(scope: ScopeConfig): Generator<Target> {
const subgroups = scope.includeSubgroups !== false;
if (hasValue(scope.project)) yield { project: decodePath(scope.project as string) };
for (const project of scope.projects ?? [])
if (hasValue(project)) yield { project: decodePath(project) };
if (hasValue(scope.group)) yield { group: decodePath(scope.group as string), subgroups };
for (const group of scope.groups ?? [])
if (hasValue(group)) yield { group: decodePath(group), subgroups };
if (hasValue(scope.namespace))
yield { group: decodePath(scope.namespace as string), subgroups: true };
}
const hasValue = (value: unknown): boolean => value !== undefined && value !== null && value !== '';
/**
* Search scopes GitLab answers for one project and for one group, so a global search under
* several targets searches each of them (lib/api/helpers/search_helpers.rb).
*/
export const COMPOSED_SEARCH_SCOPES: ReadonlySet<string> = new Set([
'issues',
'merge_requests',
'milestones',
'notes',
'wiki_blobs',
'commits',
'blobs',
'users',
]);
/**
* Why a targetless call cannot be narrowed to the scope, or null: a creation that names no
* place, a global search without a multi-target emulator, and instance-wide listings
* with no per-scope form. A group without its subgroups reads the
* group's results kept to its own projects instead.
*/
export function targetlessRestriction(tool: string, args: Args, scope: ScopeConfig): string | null {
const creation = creationRestriction(tool, args, scope);
if (creation) return creation;
if (tool === 'browse_search' && args.action === 'global') {
// Group and project search have no snippet_titles scope (lib/api/helpers/search_helpers.rb);
// scoped execution searches the scope's project snippets itself.
if (args.scope === 'snippet_titles') return null;
// The scope's groups answer a search of groups; a scope of projects reaches no group.
if (args.scope === 'groups' && scopeProjectsOnly(scope)) {
return 'groups cannot be searched under a working scope made of projects, which reaches no group';
}
// These emulators compose multiple targets; they do not need a single target rewrite.
// Groups answer a search of projects themselves, and the scope's projects are matched.
if (args.scope === 'groups' || args.scope === 'projects') return null;
if (COMPOSED_SEARCH_SCOPES.has(args.scope as string)) return null;
return unnarrowable(scope, 'a global search cannot be limited', 'search within');
}
// GitLab lists namespaces across the instance; a scope of projects reaches no group.
if (tool === 'browse_namespaces' && args.action === 'list' && scopeProjectsOnly(scope)) {
return 'namespaces cannot be listed for a working scope made of projects, which reaches no group';
}
return INSTANCE_LISTINGS[tool]?.(args) ?? null;
}
/**
* Per tool: why a listing across the whole instance (or all of the caller's activity) cannot
* be narrowed to the scope, or null. Those it can are rewritten by NARROWERS or composed by
* scoped execution. A user search (browse_users) stays the instance's user directory: users
* belong to the instance, and the search finds people not yet members of the scope, to add,
* assign or mention them. The scope's members are found with a global search of users, which
* the scope narrows to its projects and groups.
*/
const INSTANCE_LISTINGS: Record<string, (args: Args) => string | null> = {
// User events are resolved and filtered by scoped execution for groups and multiple targets.
// Deploy keys are listed per project and composed across the scope's projects; the public
// listing exists only instance-wide.
browse_deploy_keys: (args) =>
args.action === 'list' && !hasValue(args.project_id) && args.public === true
? "public deploy keys are listed for the whole instance and cannot be limited to the working scope; list a project's keys with 'project_id'"
: null,
// Public and personal snippets belong to no project: one is listed across the whole
// instance, the other across the account, and neither can be kept to the scope.
browse_snippets: (args) => {
if (args.action !== 'list') return null;
if (args.scope === 'public')
return "public snippets are listed across the whole instance only and cannot be limited to the working scope; list a project's snippets with scope 'project'";
if (args.scope === 'personal')
return "personal snippets belong to no project and cannot be limited to the working scope; list a project's snippets with scope 'project'";
return null;
},
// Personal access tokens belong to an account, not to any project or group.
browse_access_tokens: (args) =>
args.action === 'list_personal'
? "personal access tokens belong to no project or group and cannot be limited to the working scope; list a project's or a group's tokens with action 'list_project' or 'list_group'"
: null,
};
/** Why a call GitLab only offers per project or per group cannot follow several targets. */
function unnarrowable(scope: ScopeConfig, what: string, instead: string): string | null {
return singleTarget(scope)
? null
: `${what} for a working scope of several projects or groups; ${instead} one of them`;
}
/** A project created or forked: its `namespace` names the group it goes into. */
function isProjectCreation(tool: string, args: Args): boolean {
return tool === 'manage_project' && (args.action === 'create' || args.action === 'fork');
}
/** Whether a project created or forked names the group it goes into. */
function namesDestination(args: Args): boolean {
return hasValue(args.namespace) || hasValue(args.namespace_path);
}
/**
* The call's arguments as the scope check reads them. A created or forked project's
* `namespace` is the group it goes into, so it is checked as a group: the scope group itself
* is a valid place also when its subgroups are not part of the scope.
*/
export function scopeCheckArgs(tool: string, args: Args): Args {
if (!isProjectCreation(tool, args) || !hasValue(args.namespace)) return args;
// Checked as the group-valued namespace_id, which create and fork do not take, so a fork
// naming both namespace and namespace_path has each destination checked.
const { namespace, ...rest } = args;
return { ...rest, namespace_id: namespace };
}
/** A snippet created without a project, which GitLab makes a personal snippet. */
const isPersonalSnippetCreation = (tool: string, args: Args): boolean =>
tool === 'manage_snippet' && args.action === 'create' && !hasValue(args.projectId);
/**
* Creation that names no place lands outside the scope: a project created or forked without
* a namespace in the user's own namespace (filled in with the scope group when there is a
* single one), a group without a parent at the top level, a snippet without a project as a
* personal snippet (filled in with the scope project when there is a single one).
*/
function creationRestriction(tool: string, args: Args, scope: ScopeConfig): string | null {
if (isProjectCreation(tool, args) && !namesDestination(args)) {
const target = singleTarget(scope);
if (target && 'group' in target) return null;
return "a project without a namespace would be created outside the working scope; name the group to put it in with 'namespace'";
}
if (tool === 'manage_namespace' && args.action === 'create' && !hasValue(args.parent_id)) {
return "a top-level group would be outside the working scope; create it inside a group of the scope with 'parent_id'";
}
if (isPersonalSnippetCreation(tool, args)) {
const target = singleTarget(scope);
if (target && 'project' in target) return null;
return "a snippet without 'projectId' would be a personal snippet outside the working scope; name the project to create it in with 'projectId'";
}
return null;
}
/**
* The projects of a scope made of projects only, or null. Listing them directly replaces a
* project listing that GitLab paginates before the scope could filter it.
*/
export function scopeProjectsOnly(scope: ScopeConfig): string[] | null {
if (scope.group || scope.groups?.length || scope.namespace) return null;
// Consumers only read this list; borrow an existing list instead of copying a wide preset.
if (!scope.project) return scope.projects?.length ? scope.projects : null;
return [scope.project, ...(scope.projects ?? [])];
}
/** A listing that names neither a project nor a group. */
const isTargetlessList = (args: Args): boolean =>
args.action === 'list' && !hasValue(args.project_id) && !hasValue(args.group_id);
/**
* Project listing filters GET /groups/:id/projects does not apply: GroupProjectsFinder builds
* its own collection and ignores non_public (membership), and the others are not declared
* there. A listing naming one stays the instance-wide listing, filtered to the scope.
*/
const INSTANCE_ONLY_PROJECT_FILTERS = [
'membership',
'with_programming_language',
'include_deleted',
'marked_for_deletion_on',
] as const;
/** Whether a project listing applies a filter only the instance-wide listing has. */
export function needsInstanceProjectListing(args: Args): boolean {
return INSTANCE_ONLY_PROJECT_FILTERS.some((name) => {
const value = args[name];
return value !== undefined && value !== null && value !== false && value !== '';
});
}
/**
* A targetless project listing kept to one group of the scope. The caller's own exclusion of
* subgroups stays; a scope without them excludes them anyway. A plain listing lists active
* projects, a group listing only when asked: that default is kept unless the caller chose
* active or archived projects.
*/
export function groupProjectsArgs(args: Args, target: { group: string; subgroups: boolean }): Args {
return {
...args,
group_id: target.group,
include_subgroups: target.subgroups && args.include_subgroups !== false,
...(hasValue(args.active) || hasValue(args.archived) ? {} : { active: true }),
};
}
/**
* A targetless merge request listing kept to one target of the scope. The targetless listing
* shows the caller's own merge requests unless asked otherwise, while a project's or group's
* shows everyone's, so that default is kept. Both of those listings take `scope` too: it is
* declared in merge_requests_base_params, which they use, and only its created_by_me default
* is the targetless listing's own (merge_requests_helpers.rb, applied by
* IssuableFinder#by_scope). A group's listing covers its subgroups; without them it is
* filtered.
*/
export function targetMergeRequestsArgs(args: Args, target: Target): Args {
const place = 'project' in target ? { project_id: target.project } : { group_id: target.group };
return { ...args, ...place, scope: args.scope ?? 'created_by_me' };
}
/**
* Per tool: the call's arguments with the single scope target as their filter, or null
* when the call keeps its own arguments.
*/
const NARROWERS: Record<string, (args: Args, target: Target) => Args | null> = {
browse_search: (args, target) => {
if (args.action !== 'global') return null;
// Group search covers subgroups; under a scope without them its rows are filtered.
return 'group' in target
? { ...args, action: 'group', group_id: target.group }
: { ...args, action: 'project', project_id: target.project };
},
browse_projects: (args, target) =>
args.action === 'list' &&
!hasValue(args.group_id) &&
'group' in target &&
!needsInstanceProjectListing(args)
? groupProjectsArgs(args, target)
: null,
browse_merge_requests: (args, target) =>
isTargetlessList(args) ? targetMergeRequestsArgs(args, target) : null,
// The caller's own activity spans every project; a project scope shows that project's.
browse_events: (args, target) =>
args.action === 'user' && 'project' in target
? { ...args, action: 'project', project_id: target.project }
: null,
browse_snippets: (args, target) =>
args.action === 'list' &&
args.scope === 'project' &&
!hasValue(args.projectId) &&
'project' in target
? { ...args, projectId: target.project }
: null,
manage_snippet: (args, target) =>
isPersonalSnippetCreation('manage_snippet', args) && 'project' in target
? { ...args, projectId: target.project }
: null,
browse_deploy_keys: (args, target) => {
if (
args.action !== 'list' ||
hasValue(args.project_id) ||
args.public === true ||
!('project' in target)
)
return null;
// public=false is the default; the project endpoint does not accept that field.
const { public: _public, ...listing } = args;
return { ...listing, project_id: target.project };
},
// Runners available to the scope's project or group, with the same filters. The caller's
// owned runners keep their own feed and are filtered to the scope instead.
browse_runners: (args, target) => {
if (args.action !== 'list_all') return null;
return 'group' in target
? { ...args, action: 'list_group', group_id: target.group }
: { ...args, action: 'list_project', project_id: target.project };
},
// A project scope's trail is the project's; a group's holds only the group's own events, so
// scoped execution reads it with its projects' trails instead.
browse_audit_events: (args, target) =>
args.action === 'list_instance' &&
!hasValue(args.entity_type) &&
!hasValue(args.entity_id) &&
'project' in target
? { ...args, action: 'list_project', project_id: target.project }
: null,
browse_vulnerabilities: (args, target) => {
if (!isTargetlessList(args)) return null;
return 'group' in target
? { ...args, group_id: target.group }
: { ...args, project_id: target.project };
},
manage_project: (args, target) => {
if (!isProjectCreation('manage_project', args) || namesDestination(args)) return null;
if (!('group' in target)) return null;
// Fork takes the destination as namespace_path, create as namespace.
return args.action === 'fork'
? { ...args, namespace_path: target.group }
: { ...args, namespace: target.group };
},
};
/** The call's arguments with the scope as its filter, where the tool takes one. */
export function scopedArgs(tool: string, args: Args, scope: ScopeConfig): Args {
const target = singleTarget(scope);
const narrow = NARROWERS[tool];
if (!target || !narrow) return args;
return narrow(args, target) ?? args;
}
/** Project path of a listed project or merge request, as each listing reports it. */
function projectPathOf(tool: string, item: Record<string, unknown>): string | undefined {
if (tool === 'browse_projects') {
return typeof item.path_with_namespace === 'string' ? item.path_with_namespace : undefined;
}
// A merge request's references.full is "group/project!iid"
const full = (item.references as { full?: unknown } | undefined)?.full;
return typeof full === 'string' && full.includes('!')
? full.slice(0, full.lastIndexOf('!'))
: undefined;
}
/**
* GitLab's simple view of a merge request (MergeRequestSimple, lib/api/entities) has no
* references, which tell its project. A listing filtered by project reads the full view and
* returns these fields of each row kept.
*/
const SIMPLE_MERGE_REQUEST_FIELDS = [
'id',
'iid',
'project_id',
'title',
'description',
'state',
'created_at',
'updated_at',
'web_url',
] as const;
/** The arguments a listing filtered to the scope reads GitLab with: rows that tell their place. */
export function filteredReadArgs(tool: string, args: Args): Args {
if (tool !== 'browse_merge_requests' || args.view !== 'simple') return args;
const { view: _view, ...full } = args;
return full;
}
/** The fields of a merge request the simple view shows, as GitLab would have returned them. */
function simpleMergeRequest(row: Record<string, unknown>): Record<string, unknown> {
const simple: Record<string, unknown> = {};
for (const field of SIMPLE_MERGE_REQUEST_FIELDS) Eif (field in row) simple[field] = row[field];
return simple;
}
/** A listing of projects or merge requests whose rows are filtered by their project. */
function isProjectRowListing(tool: string, args: Args): boolean {
return (
(tool === 'browse_projects' && (args.action === 'search' || args.action === 'list')) ||
(tool === 'browse_merge_requests' && args.action === 'list' && !hasValue(args.project_id))
);
}
/** Whether a call's results are filtered to the scope after GitLab returned them. */
export function isFilteredListing(tool: string, args: Args): boolean {
return (
isProjectRowListing(tool, args) ||
isScopedTodoListing(tool, args) ||
isNamespaceListing(tool, args)
);
}
/**
* A todo listing not kept to one project. A group's todo filter covers its subgroups too, so
* a listing naming a group of a scope without them is filtered as well.
*/
const isScopedTodoListing = (tool: string, args: Args): boolean =>
tool === 'browse_todos' && args.action === 'list' && !hasValue(args.project_id);
/** The instance-wide namespace listing, which has no per-group form. */
const isNamespaceListing = (tool: string, args: Args): boolean =>
tool === 'browse_namespaces' && args.action === 'list';
/**
* The results of a listing narrowed to the scope. Project listings are always filtered:
* a group's listing also returns projects only shared with it from other namespaces.
* An item whose place cannot be told is left out: listing it could show something
* outside the scope.
*/
export function scopedResult(
tool: string,
args: Args,
result: unknown,
enforcer: ScopeEnforcer,
): unknown {
if (!Array.isArray(result)) return result;
if (isProjectRowListing(tool, args)) {
const kept = result.filter((item: Record<string, unknown>) => {
const path = projectPathOf(tool, item);
return path !== undefined && enforcer.isAllowed(path);
});
// Read in the full view by filteredReadArgs; returned in the one the caller asked for.
return tool === 'browse_merge_requests' && args.view === 'simple'
? kept.map(simpleMergeRequest)
: kept;
}
if (isScopedTodoListing(tool, args)) {
return result.filter((todo: Record<string, unknown>) => {
const project = (todo.project as { path_with_namespace?: unknown } | undefined)
?.path_with_namespace;
if (typeof project === 'string') return enforcer.isAllowed(project);
const group = (todo.group as { full_path?: unknown } | undefined)?.full_path;
if (typeof group === 'string') return enforcer.isGroupAllowed(group);
return false;
});
}
if (isNamespaceListing(tool, args)) {
return result.filter((namespace: Record<string, unknown>) => {
const path = namespace.full_path;
return typeof path === 'string' && enforcer.isGroupAllowed(path);
});
}
return result;
}
|