All files / src/entities/refs protection.ts

100% Statements 75/75
100% Branches 54/54
100% Functions 8/8
100% Lines 58/58

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                                              24x               2090x       19x                 4x 2x 2x   1x     24x 24x     8085x 8085x 24255x   8085x       6088x   6086x 6082x 6082x 18223x 6109x 6109x 6094x   6067x       39x 31x 4038x 31x     31x 2034x 2033x 2041x 2041x     31x                 39x 39x 4047x 4047x 4047x 38x 38x             39x                 24x 54x   54x 216x 216x 39x 39x   54x       3x 3x           54x       4x 4x           54x   36x 33x 33x   3x    
/** Verify protection settings against the entity GitLab actually returned. */
 
type AccessRule = {
  user_id?: number;
  group_id?: number;
  access_level?: number;
};
 
interface ProtectionRequest {
  unprotect_access_level?: number;
  allowed_to_create?: AccessRule[];
  allowed_to_push?: AccessRule[];
  allowed_to_merge?: AccessRule[];
  allowed_to_unprotect?: AccessRule[];
  code_owner_approval_required?: boolean;
}
 
interface UnappliedProtection {
  setting: string;
  requested: unknown;
  requires: string;
}
 
const ACCESS_FIELDS = [
  ['allowed_to_create', 'create_access_levels'],
  ['allowed_to_push', 'push_access_levels'],
  ['allowed_to_merge', 'merge_access_levels'],
  ['allowed_to_unprotect', 'unprotect_access_levels'],
] as const;
 
function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}
 
function isRoleGrant(rule: Record<string, unknown>): boolean {
  return (
    rule.user_id == null &&
    rule.group_id == null &&
    rule.deploy_key_id == null &&
    rule.member_role_id == null
  );
}
 
function containsRole(level: number, actual: unknown): boolean {
  if (!Array.isArray(actual)) return false;
  for (const entry of actual) {
    if (isRecord(entry) && entry.access_level === level && isRoleGrant(entry)) return true;
  }
  return false;
}
 
const SELECTORS = ['user_id', 'group_id', 'access_level'] as const;
const ROLE_MASK = 1 << 2;
 
function selectorMask(rule: AccessRule): number {
  let mask = 0;
  for (let field = 0; field < SELECTORS.length; field++) {
    if (rule[SELECTORS[field]] !== undefined) mask |= 1 << field;
  }
  return mask;
}
 
function ruleKey(rule: Record<string, unknown>, mask: number): string | undefined {
  if (mask === 0) return undefined;
  // An access-level-only request means a role-wide grant, not a named identity's role.
  if (mask === ROLE_MASK && !isRoleGrant(rule)) return undefined;
  let key = String(mask);
  for (let field = 0; field < SELECTORS.length; field++) {
    if ((mask & (1 << field)) === 0) continue;
    const value = rule[SELECTORS[field]];
    if (typeof value !== 'number') return undefined;
    key += `:${value}`;
  }
  return key;
}
 
function indexRules(rules: AccessRule[], actual: unknown): Set<string> | undefined {
  if (rules.length === 0 || !Array.isArray(actual)) return undefined;
  const masks = new Set<number>();
  for (const rule of rules) masks.add(selectorMask(rule));
  const keys = new Set<string>();
  // Three optional selectors give at most seven nonempty shapes, so verification is
  // O(requested + returned), including duplicate grants or mixed selector objects.
  for (const entry of actual) {
    if (!isRecord(entry)) continue;
    for (const mask of masks) {
      const key = ruleKey(entry, mask);
      if (key !== undefined) keys.add(key);
    }
  }
  return keys;
}
 
function collectUnappliedRules(
  setting: string,
  rules: AccessRule[],
  actual: unknown,
  notApplied: UnappliedProtection[] | undefined,
): UnappliedProtection[] | undefined {
  const applied = indexRules(rules, actual);
  for (let index = 0; index < rules.length; index++) {
    const rule = rules[index];
    const key = ruleKey(rule, selectorMask(rule));
    if (key !== undefined && applied?.has(key)) continue;
    notApplied ??= [];
    notApplied.push({
      setting: `${setting}[${index}]`,
      requested: rule,
      requires:
        'GitLab EE for access lists; Premium or Ultimate for user/group grants, and project membership for each grant',
    });
  }
  return notApplied;
}
 
/**
 * CE ignores EE access lists; EE permits role entries but licenses user/group grants.
 * GitLab 16.0: app/services/protected_refs/access_level_params.rb and
 * ee/app/models/concerns/ee/protected_ref_access.rb (role exception to license checks).
 * https://gitlab.com/gitlab-org/gitlab/-/blob/v16.0.0-ee/ee/app/models/concerns/ee/protected_ref_access.rb
 */
export function withUnappliedProtection(requested: ProtectionRequest, response: unknown): unknown {
  const returned = isRecord(response) ? response : undefined;
  let notApplied: UnappliedProtection[] | undefined;
  for (const [setting, responseField] of ACCESS_FIELDS) {
    const rules = requested[setting];
    if (rules === undefined) continue;
    const actual = returned?.[responseField];
    notApplied = collectUnappliedRules(setting, rules, actual, notApplied);
  }
  if (
    requested.unprotect_access_level !== undefined &&
    !containsRole(requested.unprotect_access_level, returned?.unprotect_access_levels)
  ) {
    notApplied ??= [];
    notApplied.push({
      setting: 'unprotect_access_level',
      requested: requested.unprotect_access_level,
      requires: 'GitLab EE with role-based branch-unprotection settings',
    });
  }
  if (
    requested.code_owner_approval_required !== undefined &&
    returned?.code_owner_approval_required !== requested.code_owner_approval_required
  ) {
    notApplied ??= [];
    notApplied.push({
      setting: 'code_owner_approval_required',
      requested: requested.code_owner_approval_required,
      requires: 'GitLab Premium or Ultimate with code owner approval available for this project',
    });
  }
  if (!notApplied) return response;
  // The handler owns this freshly parsed response; attaching diagnostics needs no entity copy.
  if (returned) {
    returned.not_applied = notApplied;
    return returned;
  }
  return { result: response, not_applied: notApplied };
}