Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- GitLab org-policy discovery now walks the subgroup tree from the project's own group up to the top-level group and applies the closest `apm-policy` project, so a team can scope its own governance under a subgroup instead of only the top-level group; a subgroup policy can `extends:` an ancestor group's policy (including nested-namespace references). Flat `<group>/<project>` remotes are unchanged. (closes #2753) (#2933)
- gh-aw's shared APM import now supports `token-source: github-token`; after consumers re-vendor the workflow, its read-only current-repository identity can fetch same-repository private packages, while `cascade` remains the default and cross-repository packages still require a dedicated token or GitHub App. (#2706)
- OpenAPM v0.1 adds `req-pl-018` for dependency-policy identity casing and amends `req-rs-016` clause (3), the Section 6.4 merge rules, and the Section 6.5 pattern grammar so repository identity and policy matching cannot diverge; Section 11.2 item 6 now requires the per-host case rule in `CONFORMANCE.md`. (#2706)

Expand Down
21 changes: 18 additions & 3 deletions docs/src/content/docs/enterprise/apm-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,11 +74,26 @@ On GitHub and GitHub API-compatible hosts, the `.github-private` repo is preferr

```
<top-level-group>/
apm-policy/
apm-policy.yml # auto-discovered by projects whose remote starts with <top-level-group>/
[<subgroups>/] # optional -- apm-policy may sit at any level
apm-policy/
apm-policy.yml # discovered by projects under this group or any descendant
```

GitLab discovery uses only the top-level group: APM takes the first path segment from the project remote and looks for `<top-level-group>/apm-policy`. It does not search nested subgroup scopes. Set `APM_GITLAB_POLICY_REPO` to use a different project name if your org already publishes policy under another name. A project without `apm-policy` (or the configured override) is treated as a clean "no policy" outcome, matching the fallthrough behaviour on GitHub and ADO -- it does not print a warning.
GitLab discovery walks the subgroup tree from the project's own group up to the top-level group, and the **closest** `apm-policy` wins. For a project remote `gitlab.com/acme/dept-a/team-x/my-project`, APM probes in order:

1. `acme/dept-a/team-x/apm-policy`
2. `acme/dept-a/apm-policy`
3. `acme/apm-policy`

This lets an individual team publish its own `apm-policy` under its subgroup without affecting the rest of the organization, while a top-level `acme/apm-policy` still applies to every project that has no closer policy. A flat `<group>/<project>` remote (including a personal namespace like `gitlab.com/<user>/<project>`) probes only `<group>/apm-policy`, identical to the previous behaviour.

Composing policies across levels is opt-in via `extends:`. A team policy can extend an ancestor group's policy to inherit and tighten it, using either an explicit path (`extends: "acme/dept-a/apm-policy"` for the immediate parent group, or a deeper ancestor) or the `extends: "org"` shorthand, which on GitLab always resolves to the **top-level** group's policy (`<top-level-group>/apm-policy`) -- not the nearest ancestor. Children can only tighten, never relax.
Comment thread
Copilot marked this conversation as resolved.

:::note[Host-qualified `extends:` on GitLab]
A bare nested `extends:` ref whose first segment contains a dot (e.g. a group named `acme.tools`) is read as a host, so `extends: "acme.tools/team/apm-policy"` is rejected as cross-host. Spell it host-qualified instead -- `extends: "gitlab.com/acme.tools/team/apm-policy"` (or `<GITLAB_HOST>/...` on self-managed) -- so the leaf host is matched and stripped. When the project's remote uses an explicit port, the host-qualified ref must carry the exact `host:port` authority (e.g. `gitlab.example.com:8443/acme/team/apm-policy`); `GITLAB_HOST` is only the hostname, so a ref without the matching port is rejected.
:::

Set `APM_GITLAB_POLICY_REPO` to use a different project name if your org already publishes policy under another name. A project with no `apm-policy` at any level (or the configured override) is treated as a clean "no policy" outcome, matching the fallthrough behaviour on GitHub and ADO -- it does not print a warning.

:::caution[Self-managed GitLab requires GITLAB_HOST or APM_GITLAB_HOSTS]
An arbitrary FQDN is never auto-classified as GitLab -- the same domain shape could be Bitbucket, Gitea, or a plain git server. `gitlab.com` is recognised automatically, but a self-managed instance (e.g. `gitlab.example.com`) is only recognised once you set `GITLAB_HOST=gitlab.example.com` (or `APM_GITLAB_HOSTS` for more than one instance). Without it, APM falls through to the GitHub-style cascade above, which is invalid on GitLab and behaves exactly like the unfixed discovery this section describes. This mirrors `GITHUB_HOST` for GitHub Enterprise Server and `ADO_HOST` for on-prem Azure DevOps Server -- see [Environment Variables](../../reference/environment-variables/).
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/enterprise/governance-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ These are the sharp edges. Plan around them; do not assume they are solved.
- **`apm compile` and `apm run` do not re-check policy.** They trust install. Operational mitigation: ensure that no compile or run step in CI is reachable without a preceding `apm install` that ran the gate.
- **`apm audit --ci` in `warn` mode rewrites violations to `passed=True`.** Warn mode never fails CI exit. The visibility is in the SARIF output, not the exit code. Operational mitigation: monitor Code Scanning alerts during the warn-mode rollout phase; do not assume CI green means "no policy violations" while in warn mode.
- **`apm install` has no `--policy <path>` flag.** Only `apm audit` does. This is the air-gapped install gap. Operational mitigation: use `extends:` from a reachable mirror, or run audit (which does support `--policy <path>`) as the gating check and skip install-time enforcement in air-gapped CI.
- **Plain git remotes are not auto-discovered.** GitHub and GitHub Enterprise use the `.github` cascade, Azure DevOps uses the org `apm` project and `apm-policy` repository (with `_apm/_apm` as a legacy fallback), and GitLab uses `<top-level-group>/apm-policy/apm-policy.yml`, where the top-level group is the first path segment of the remote. Nested subgroup scopes are not searched. Configure self-managed GitLab with `GITLAB_HOST` or `APM_GITLAB_HOSTS`. For unsupported remotes, pass `apm audit --ci --policy <path-or-url>` explicitly.
- **Plain git remotes are not auto-discovered.** GitHub and GitHub Enterprise use the `.github` cascade, Azure DevOps uses the org `apm` project and `apm-policy` repository (with `_apm/_apm` as a legacy fallback), and GitLab walks the subgroup tree from the project's own group up to the top-level group and applies the closest `apm-policy` (see [Policy Files](./apm-policy/#where-it-lives)). Configure self-managed GitLab with `GITLAB_HOST` or `APM_GITLAB_HOSTS`. For unsupported remotes, pass `apm audit --ci --policy <path-or-url>` explicitly.
- **Trust anchor is `git remote get-url origin`.** A developer who pushes the project to a personal org will have policy discovery resolve `<their-org>/.github/apm-policy.yml` -- which they control. Operational mitigation: branch protection on the canonical repo is the trust boundary; nothing about a personal fork can bypass what your CI requires before merge.
- **`apm install --dry-run` silently downgrades hash-mismatch.** In dry-run, `raise_blocking_errors=False` (outcome_routing.py:104-119) causes the mismatch to surface as `discovery_miss` with no "Would be blocked" line and exit 0. Operational mitigation: rely on `apm audit --ci` in CI for hash-pin verification, not on `apm install --dry-run`.
- **`apm audit --ci --no-policy` and `APM_POLICY_DISABLE=1` skip policy checks.** The 21 policy checks are bypassed in audit, but the 8 baseline lockfile checks still run. Operational mitigation: keep bypass flags out of required CI workflows; the bypass contract in section 7 is authoritative.
Expand Down
10 changes: 6 additions & 4 deletions docs/src/content/docs/enterprise/policy-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ Inherit from a parent policy. See [Inheritance](#inheritance).

| Value | Source |
|-------|--------|
| `org` | Parent org's `.github-private/apm-policy.yml` (falls back to `.github`) |
| `org` | Parent org's `.github-private/apm-policy.yml` (falls back to `.github`); on GitLab, the top-level group's `apm-policy` |
| `owner/repo` | Cross-org policy from a specific repository |
| `namespace/.../repo` | On GitLab, an ancestor subgroup's policy, e.g. `acme/dept-a/apm-policy` (host-qualify a namespace whose first segment contains a dot) |
| `https://...` | Direct URL to a policy file |

### `fetch_failure`
Expand Down Expand Up @@ -475,7 +476,7 @@ There are 21 policy checks.
## Inheritance

:::note[Discovery vs. `extends:` -- two different concepts]
APM auto-discovers exactly **one** policy file: `<org>/.github/apm-policy.yml`, derived from the project's git remote. There is no automatic per-repo or per-enterprise discovery. `extends:` is what composes policies **inside** that one discovered file -- it lets the discovered policy pull in a parent (and that parent's parent, up to `MAX_CHAIN_DEPTH=5`) so you can model an enterprise -> org -> team chain through composition. Most teams who say "3 levels (repo, org, enterprise)" actually want `extends:`, not more discovery sites.
APM auto-discovers exactly **one** policy file per project, derived from the git remote: on GitHub `<org>/.github/apm-policy.yml`, and on GitLab the **closest** `apm-policy` walking up the subgroup tree to the top-level group (see [Policy Files](./apm-policy/#where-it-lives)). There is no automatic per-repo or per-enterprise discovery. `extends:` is what composes policies **inside** that one discovered file -- it lets the discovered policy pull in a parent (and that parent's parent, up to `MAX_CHAIN_DEPTH=5`) so you can model an enterprise -> org -> team chain through composition. Most teams who say "3 levels (repo, org, enterprise)" actually want `extends:`, not more discovery sites.
:::

Policies can inherit from a parent using `extends`. This enables a three-level chain:
Expand Down Expand Up @@ -619,8 +620,9 @@ found in the organization cascade: `.github-private`, `.github`, `.apm`, then
`_apm`. Azure DevOps remotes use the org `apm` project and `apm-policy`
repository. Legacy `_apm/_apm` is a temporary fallback after a 404 from the
primary coordinate.
GitLab remotes use `<top-level-group>/apm-policy/apm-policy.yml`, using the
first path segment of the remote; nested subgroup scopes are not searched. Set
GitLab walks the subgroup tree from the project's own group up to the top-level
group and applies the closest `apm-policy` project; a flat `<group>/<project>`
remote probes only `<group>/apm-policy`. Set
Comment thread
Copilot marked this conversation as resolved.
`GITLAB_HOST` or `APM_GITLAB_HOSTS` to recognize a self-managed host, and use
`APM_GITLAB_POLICY_REPO` to select another project name. Plain git remotes
fall through with no policy applied. Repositories with no detectable git remote
Expand Down
13 changes: 9 additions & 4 deletions packages/apm-guide/.apm/skills/apm-usage/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,9 +423,13 @@ may use. This section covers how that contract is enforced at `apm install` time
APM auto-discovers org policy from the project's git remote by checking
`.github-private`, `.github`, `.apm`, and `_apm` policy repos in order on GitHub
API-compatible hosts. Azure DevOps hosts use repository `apm-policy` in project
`apm`, with a legacy `_apm/_apm` fallback after a 404. GitLab uses
`<top-level-group>/apm-policy/apm-policy.yml`, derived from the first remote
path segment; nested subgroup scopes are not searched. Configure a self-managed host with
`apm`, with a legacy `_apm/_apm` fallback after a 404. GitLab walks the subgroup
tree from the project's own group up to the top-level group and applies the
closest `apm-policy` (e.g. `acme/dept-a/team-x/apm-policy` before
`acme/dept-a/apm-policy` before `acme/apm-policy`), so a team can scope its own
policy under a subgroup; a team policy can `extends:` an ancestor group's policy
to inherit it. A flat `<group>/<project>` remote probes only `<group>/apm-policy`.
Configure a self-managed host with
`GITLAB_HOST` or `APM_GITLAB_HOSTS`, and use `APM_GITLAB_POLICY_REPO` to select
another project name. Repositories with no detectable git remote (unpacked
bundles, temp dirs) emit an explicit "could not determine org" line and skip
Expand Down Expand Up @@ -675,7 +679,8 @@ as `[x]` errors and exit `1`.
Checklist to publish a policy:

1. Create `apm-policy.yml` in the org policy repo (`.github-private` or `.github` on GitHub, `apm`
project and `apm-policy` repository on Azure DevOps, or `apm-policy` under the top-level GitLab group).
project and `apm-policy` repository on Azure DevOps, or an `apm-policy` project under the GitLab
group you want it to govern -- any ancestor namespace of the project, closest wins).
2. Start from the recommended starter below and trim to the minimum reflecting
your governance posture.
3. Set `enforcement: warn` first. Let CI surface diagnostics across consuming
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

from scripts.architecture_linter.checks.install_policy_shared import (
_APM_RESOLVER,
_ELSE_TERMINATOR,
_POLICY_DISCOVERY,
_banned,
_configured,
Expand Down Expand Up @@ -43,6 +42,9 @@
RULE_WINNER_SELECTION = "install-deployment-dependency-winner-selection"


RULE_REMOTE_ORIGIN_OWNER = "install-deployment-policy-remote-origin-owner"


def _count_text(lines: Sequence[tuple[int, str]], needle: str) -> int:
"""Return how many lines contain `needle` (``grep -Fc``)."""
return sum(1 for _, text in lines if needle in text)
Expand Down Expand Up @@ -113,7 +115,75 @@ def check_gitlab_policy_adapter(provider: FactsProvider) -> tuple[Violation, ...
return tuple(findings)


_GITLAB_BRANCH_START = re.compile(r"^[ \t]*elif is_gitlab_hostname\(host\):")
_REMOTE_ORIGIN_ARGV = re.compile(r'"remote",\s*"get-url",\s*"origin"')


_REMOTE_PARSER_DEFS = re.compile(
r"^def (_remote_url_parts|_parse_remote_url|_git_remote_origin_url)\("
)


_REMOTE_PARSER_DEF_COUNT = 3


def check_policy_remote_origin_owner(provider: FactsProvider) -> tuple[Violation, ...]:
"""Reading and parsing the project git remote for policy discovery has one owner.

``discovery.py`` is the sole reader of ``git remote get-url origin`` and the
sole home of the remote-URL splitter/parsers (``_remote_url_parts``,
``_parse_remote_url``, ``_git_remote_origin_url``). The owner MUST define all
three helpers, and no other module in the policy tree may re-read or re-parse
the remote -- either would reintroduce the double-read / divergent-parse the
single-owner refactor removed (#2753).
"""
rule_id = RULE_REMOTE_ORIGIN_OWNER
owner, owner_fail = _configured(provider, _POLICY_DISCOVERY, rule_id)
if owner_fail:
return tuple(owner_fail)
findings: list[Violation] = []
definitions = _count_re(owner, _REMOTE_PARSER_DEFS)
if definitions != _REMOTE_PARSER_DEF_COUNT:
findings.append(
_report(
rule_id,
_POLICY_DISCOVERY,
"Policy discovery must define exactly "
f"{_REMOTE_PARSER_DEF_COUNT} canonical git-remote read/parse helpers "
f"(found {definitions})",
)
)
findings.extend(
_banned(
provider,
rule_id=rule_id,
paths=_tree_python_paths(provider, _POLICY_TREE, excluded=(_POLICY_DISCOVERY,)),
pattern=_REMOTE_ORIGIN_ARGV,
Comment thread
Copilot marked this conversation as resolved.
message="Read the git remote origin only via discovery.py::_git_remote_origin_url",
configured=False,
respect_exempt=True,
)
)
findings.extend(
_banned(
provider,
rule_id=rule_id,
paths=_tree_python_paths(provider, _POLICY_TREE, excluded=(_POLICY_DISCOVERY,)),
pattern=_REMOTE_PARSER_DEFS,
message="Remote-URL split/parse owner is discovery.py; do not redefine these helpers",
configured=False,
respect_exempt=True,
)
)
return tuple(findings)


# The facade delegation now lives in ``_gitlab_walk_candidate`` (the GitLab
# branch of ``_auto_discover`` calls it; see #2753). Scope the orchestration
# scan to that helper's body -- from its ``def`` to the next top-level ``def``.
_GITLAB_WALK_START = re.compile(r"^def _gitlab_walk_candidate\(")


_TOP_LEVEL_DEF = re.compile(r"^def ")


_NON_WHITESPACE = re.compile(r"[^\s]")
Expand All @@ -134,8 +204,8 @@ def check_gitlab_facade_orchestration(provider: FactsProvider) -> tuple[Violatio

branch = _indent_scoped_branch(
lines,
start=_GITLAB_BRANCH_START,
terminator=_ELSE_TERMINATOR,
start=_GITLAB_WALK_START,
terminator=_TOP_LEVEL_DEF,
probe=_NON_WHITESPACE,
include_start=False,
restart_skips=True,
Expand Down
7 changes: 7 additions & 0 deletions scripts/architecture_linter/checks/install_policy_intent.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
RULE_GITLAB_ADAPTER,
RULE_GITLAB_FACADE,
RULE_LOCAL_BUNDLE_PREFLIGHT,
RULE_REMOTE_ORIGIN_OWNER,
RULE_REQUIRE_HASHES,
RULE_WINNER_SELECTION,
check_dependency_winner_selection,
check_gitlab_facade_orchestration,
check_gitlab_policy_adapter,
check_local_bundle_preflight,
check_policy_remote_origin_owner,
check_require_hashes_enforcement,
)
from scripts.architecture_linter.checks.install_policy_plugin_and_approval import (
Expand Down Expand Up @@ -101,6 +103,11 @@
"GitLab policy cache and transport remain in policy/_gitlab.py.",
check_gitlab_facade_orchestration,
),
_semantic_rule(
RULE_REMOTE_ORIGIN_OWNER,
"Policy discovery reads and parses the git remote through one owner (discovery.py).",
check_policy_remote_origin_owner,
),
Comment thread
Copilot marked this conversation as resolved.
Outdated
_semantic_rule(
RULE_LOCAL_BUNDLE_PREFLIGHT,
"Local bundle installs route policy through install_preflight.py.",
Expand Down
47 changes: 30 additions & 17 deletions src/apm_cli/policy/_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,26 +136,39 @@ def _fetch_gitlab_chain_parent(
)
org = current_org
else:
parts = parent_ref.strip("/").split("/")
if len(parts) == 3:
parts = [p for p in parent_ref.strip("/").split("/") if p]
invalid = PolicyFetchResult(
source=f"org:{parent_ref}",
error=f"Invalid GitLab policy reference: {parent_ref}",
outcome="cache_miss_fetch_fail",
)
# A host-like first segment (FQDN or ``host:port``) MUST match the leaf
# host and port exactly, then it is stripped so ``host/namespace/.../repo``
# and ``namespace/.../repo`` are treated the same. A host-like segment
# that does not match the leaf -- or carries a malformed port -- is a
# cross-host/invalid ref and is rejected, never silently folded into the
# namespace. A bare single-label first segment is a namespace segment
# (GitLab nested subgroups, see #2753) and is left in place.
if len(parts) >= 3 and ("." in parts[0] or ":" in parts[0]):
try:
explicit = urlsplit(f"//{parts[0]}")
same_leaf = (
explicit.hostname is not None
and explicit.hostname.lower() == leaf_host.lower()
and explicit.port == port
)
except ValueError:
explicit = None
if (
explicit is not None
and explicit.hostname is not None
and explicit.hostname.lower() == leaf_host.lower()
and explicit.port == port
):
parts = parts[1:]
if len(parts) != 2:
return PolicyFetchResult(
source=f"org:{parent_ref}",
error=f"Invalid GitLab policy reference: {parent_ref}",
outcome="cache_miss_fetch_fail",
)
org, repo = parts
same_leaf = False
if not same_leaf:
return invalid
parts = parts[1:]
# A GitLab namespace may be nested (subgroups): everything before the
# final segment is the namespace, the final segment is the policy repo.
# Requires at least ``namespace/repo``.
if len(parts) < 2:
return invalid
org = "/".join(parts[:-1])
repo = parts[-1]
return _fetch_from_gitlab_repo(
org=org,
repo=repo,
Expand Down
Loading