Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
7 changes: 7 additions & 0 deletions .apm/architecture/owners/install-deployment.json
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@
"owner": "models/dependency/selection.py (via DependencyReference)",
"selectors": ["src/apm_cli/models/dependency/selection.py"],
"guards": ["install-deployment-uninstall-selection"]
},
{
"id": "policy-remote-origin-reader",
"decision": "Reading and parsing the project git remote for org-policy discovery",
"owner": "policy/_remote.py (_git_remote_origin_url, _remote_url_parts, _parse_remote_url, _extract_org_host_port_from_git_remote)",
"selectors": ["src/apm_cli/policy/_remote.py"],
"guards": ["install-deployment-policy-remote-origin-owner"]
}
]
}
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
25 changes: 22 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,30 @@ 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.

:::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[GitLab conceals private projects with 404]
GitLab returns HTTP 404 both for a missing `apm-policy` project and for a private one the token cannot read. A closer policy the CI token is denied is therefore indistinguishable from "no policy at this level," so the walk skips it and applies the next ancestor -- the same `404 == no policy` behaviour GitHub and ADO discovery already have. Grant the CI token read access to every `apm-policy` project it should honour, so a closer policy is never silently skipped.
:::

:::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
3 changes: 2 additions & 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,8 @@ 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.
- **GitLab closest-wins is not tighten-only, and a closer policy the token cannot read is skipped.** Two edges of subgroup-scoped discovery: (1) anyone who can create an `apm-policy` project in an intermediate subgroup can publish a *weaker* policy that overrides a stricter ancestor for every project under that subgroup (closest wins; discovery does not enforce tighten-only -- only `extends:` chains do). Operational mitigation: restrict who can create `apm-policy` projects at the GitLab RBAC layer. (2) GitLab 404s a private project the token cannot read, so a closer `apm-policy` the CI token is denied is indistinguishable from "no policy here" and is skipped in favour of the next ancestor (the same `404 == no policy` behaviour as GitHub/ADO). Operational mitigation: grant the CI token read access to every `apm-policy` project it should honour. See [Policy Files](./apm-policy/#where-it-lives).
- **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
17 changes: 13 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,17 @@ 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`.
GitLab 404s a private project the token cannot read, so a closer `apm-policy` the
token is denied is indistinguishable from "no policy here" and is skipped in
favour of the next ancestor (same `404 == no policy` behaviour as GitHub/ADO) --
grant the token read access to every `apm-policy` it should honour.
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 +683,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,94 @@ 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"
r"|_extract_org_host_port_from_git_remote)\("
)


_REMOTE_PARSER_DEF_COUNT = 4


_REMOTE_ORIGIN_READ_COUNT = 1


_REMOTE_MODULE = "src/apm_cli/policy/_remote.py"


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

``policy/_remote.py`` is the sole reader of ``git remote get-url origin`` and
the sole home of the remote-URL splitter/parsers/identity extractor
(``_remote_url_parts``, ``_parse_remote_url``, ``_git_remote_origin_url``,
``_extract_org_host_port_from_git_remote``). The owner MUST define all four,
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, _REMOTE_MODULE, 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,
_REMOTE_MODULE,
"policy/_remote.py must define exactly "
f"{_REMOTE_PARSER_DEF_COUNT} canonical git-remote read/parse helpers "
f"(found {definitions})",
)
)
origin_reads = _count_re(owner, _REMOTE_ORIGIN_ARGV)
if origin_reads != _REMOTE_ORIGIN_READ_COUNT:
findings.append(
_report(
rule_id,
_REMOTE_MODULE,
"policy/_remote.py must read the git remote origin exactly "
f"{_REMOTE_ORIGIN_READ_COUNT} time via _git_remote_origin_url "
f"(found {origin_reads} origin-read argv occurrences)",
)
)
findings.extend(
_banned(
provider,
rule_id=rule_id,
paths=_tree_python_paths(provider, _POLICY_TREE, excluded=(_REMOTE_MODULE,)),
pattern=_REMOTE_ORIGIN_ARGV,
Comment thread
Copilot marked this conversation as resolved.
message="Read the git remote origin only via _remote.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=(_REMOTE_MODULE,)),
pattern=_REMOTE_PARSER_DEFS,
message="Remote-URL split/parse owner is _remote.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 +223,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
Loading