-
Notifications
You must be signed in to change notification settings - Fork 0
Feature: Enable cross-organization project access with GH_PROJECT_ONLY_TOKEN #16
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -35,7 +35,7 @@ permissions: | |||||||||
| actions: read | ||||||||||
| issues: write | ||||||||||
| security-events: write | ||||||||||
| repository-projects: read | ||||||||||
| repository-projects: write | ||||||||||
|
|
||||||||||
| jobs: | ||||||||||
| scan: | ||||||||||
|
|
@@ -50,3 +50,6 @@ jobs: | |||||||||
| AQUA_GROUP_ID: ${{ secrets.AQUA_GROUP_ID }} | ||||||||||
| AQUA_REPOSITORY_ID: ${{ secrets.AQUA_REPOSITORY_ID }} | ||||||||||
| TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} | ||||||||||
| # Required only when project-org differs from this repository's org. | ||||||||||
| # See docs/security/security.md – "Cross-org project token" for how to create it. | ||||||||||
|
Comment on lines
+53
to
+54
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
If it really is about differing from THIS repository's org, it is always AbsaOSS. |
||||||||||
| GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }} | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -16,6 +16,7 @@ In one sentence: SARIF uploads create alerts; these scripts sync alerts into Iss | |||||
| - [Available reusable workflows](#available-reusable-workflows) | ||||||
| - [How to adopt a shared workflow](#how-to-adopt-a-shared-workflow) | ||||||
| - [Aquasec Night Scan](#aquasec-night-scan) | ||||||
| - [Cross-org project token](#cross-org-project-token) | ||||||
| - [Remove sec:adept-to-close on close](#remove-secadept-to-close-on-close) | ||||||
| - [Labels (contract)](#labels-contract) | ||||||
| - [Issue metadata (secmeta)](#issue-metadata-secmeta) | ||||||
|
|
@@ -163,6 +164,7 @@ The caller needs the following **repository secrets** configured: | |||||
| | `AQUA_GROUP_ID` | yes | AquaSec group identifier | | ||||||
| | `AQUA_REPOSITORY_ID` | yes | AquaSec repository identifier | | ||||||
| | `TEAMS_WEBHOOK_URL` | no | Teams Incoming Webhook URL for new/reopened issue alerts | | ||||||
| | `GH_PROJECT_ONLY_TOKEN` | no (required for cross-org projects) | Classic PAT with `project` scope on an account that is a member of the org owning the ProjectV2 board – see [Cross-org project token](#cross-org-project-token) | | ||||||
|
|
||||||
| Example caller (already available in [aquasec-night-scan.yml](/docs/security/example_workflows/aquasec-night-scan.yml)): | ||||||
|
|
||||||
|
|
@@ -195,6 +197,53 @@ jobs: | |||||
| TEAMS_WEBHOOK_URL: ${{ secrets.TEAMS_WEBHOOK_URL }} | ||||||
| ``` | ||||||
|
|
||||||
| ##### Cross-org project token | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpicking: having a 5th level header for this big chapter is not correct. Should this doc be refactored in the future less needed PR. |
||||||
|
|
||||||
| When `project-org` is a **different organisation** than the one that owns the calling repository, the automatic `github.token` cannot resolve the ProjectV2 board and you will see: | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
|
||||||
| ``` | ||||||
| WARNING - GraphQL call failed: gh: Could not resolve to a ProjectV2 with the number <N>. | ||||||
| WARNING - Could not load project #<N> metadata – priority sync disabled | ||||||
| ``` | ||||||
|
|
||||||
| The fix is a **Personal Access Token (classic)** with the `project` scope, created by an account that is a member of the org owning the project board. | ||||||
|
|
||||||
| > **Why classic and not fine-grained?** Fine-grained PATs require the org admin to explicitly allow them under org settings. If that is not enabled, a classic PAT with the `project` scope is the practical alternative. Classic PATs are less granular (they apply to all orgs the account belongs to) but the `project` scope is the minimum checkbox needed and grants no code or issue access. | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we need to overwhelm people with information why it is not working? |
||||||
|
|
||||||
| **Step-by-step: create the token** | ||||||
|
|
||||||
| 1. Log in to the GitHub account that is a **member of the org that owns the project board** (e.g. `your-org`). Using a dedicated service/bot account is recommended over a personal account so the token does not expire when someone leaves. | ||||||
| 2. Go to **that account's Settings → Developer settings → Personal access tokens → Tokens (classic)**. | ||||||
| 3. Click **Generate new token (classic)**. | ||||||
| 4. Fill in the form: | ||||||
| - **Note**: something descriptive, e.g. `aquasec-alert-to-issues-priority-sync` | ||||||
| - **Expiration**: choose a date; calendar a renewal reminder | ||||||
| - **Scopes**: check **`project`** (labelled *"Full control of user projects"*) — this is the only scope needed. Do **not** check `repo`, `admin:org`, or anything else. | ||||||
| 5. Click **Generate token** and copy the value immediately (it is shown only once). | ||||||
|
|
||||||
| **Step-by-step: store the token in the calling repository** | ||||||
|
|
||||||
| 1. In **your application repository** (the adopting repo, in your own org), go to **Settings → Secrets and variables → Actions**. | ||||||
| 2. Click **New repository secret**. | ||||||
| 3. Name: `GH_PROJECT_ONLY_TOKEN`, Value: paste the token from the step above. | ||||||
| 4. Save. | ||||||
|
|
||||||
| The example caller workflow already passes this secret: | ||||||
|
|
||||||
| ```yaml | ||||||
| secrets: | ||||||
| GH_PROJECT_ONLY_TOKEN: ${{ secrets.GH_PROJECT_ONLY_TOKEN }} | ||||||
| ``` | ||||||
|
|
||||||
| The reusable workflow forwards it to the Python script as the `GH_PROJECT_ONLY_TOKEN` environment variable. The script uses it **only** for ProjectV2 GraphQL calls; all other operations (issue list/create/update) continue to use the scoped `github.token`. | ||||||
|
|
||||||
| **Minimum required scope summary** | ||||||
|
|
||||||
| | Scope | Reason | | ||||||
| | --- | --- | | ||||||
| | `project` | Read/write access to org-level ProjectV2 — query metadata + set Priority field values | | ||||||
| | *(everything else)* | Not needed — do not check | | ||||||
|
|
||||||
|
Comment on lines
+231
to
+246
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would keep the docs as little as needed to hand over the information to the final consumer. |
||||||
| #### Remove sec:adept-to-close on close | ||||||
|
|
||||||
| Example caller (already available in [remove-adept-to-close-on-issue-close.yml](/docs/security/example_workflows/remove-adept-to-close-on-issue-close.yml)): | ||||||
|
|
@@ -465,6 +514,9 @@ As of 2026-03, `promote_alerts.py` implements the fingerprint-based sync loop de | |||||
| - Permission errors in Actions: ensure the workflow has `security-events: read` and `issues: write` permissions. | ||||||
| - `Output file alerts.json exists`: `collect_alert.py` refuses to overwrite output; delete the file, pass a different `--out` path, or run via `sync_security_alerts.py --force` (which deletes the file before invoking the collector). | ||||||
| - `missing 'alert hash' in alert message`: the scanner/collector needs to include an `Alert hash: ...` line in the alert instance message text. | ||||||
| - `GraphQL call failed: gh: Could not resolve to a ProjectV2 with the number N` / `Could not load project #N metadata – priority sync disabled`: the token used for the GraphQL call cannot see the project. Two causes: | ||||||
| - **Same org, wrong permission**: ensure the caller workflow has `repository-projects: write` (not `read`). | ||||||
| - **Cross-org**: the calling repository is in a different org than the project board. Create a classic PAT with the `project` scope on an account that is a member of the project-owning org, and store it as `GH_PROJECT_ONLY_TOKEN` in the calling repository's secrets. See [Cross-org project token](#cross-org-project-token). | ||||||
|
|
||||||
| ## References | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| import os | ||
| import re | ||
| import subprocess | ||
| from collections.abc import Mapping | ||
| from datetime import datetime, timezone | ||
|
|
||
|
|
||
|
|
@@ -67,16 +68,26 @@ def normalize_path(path: str | None) -> str: | |
| return p | ||
|
|
||
|
|
||
| def run_cmd(cmd: list[str], *, capture_output: bool = True) -> subprocess.CompletedProcess: | ||
| def run_cmd( | ||
| cmd: list[str], | ||
| *, | ||
| capture_output: bool = True, | ||
| env: Mapping[str, str] | None = None, | ||
| ) -> subprocess.CompletedProcess: | ||
| """Run *cmd* as a subprocess and return the completed process.""" | ||
| return subprocess.run(cmd, check=False, capture_output=capture_output, text=True) | ||
| return subprocess.run(cmd, check=False, capture_output=capture_output, text=True, env=env) | ||
|
|
||
|
|
||
| def run_gh(args: list[str], *, capture_output: bool = True) -> subprocess.CompletedProcess: | ||
| def run_gh( | ||
| args: list[str], | ||
| *, | ||
| capture_output: bool = True, | ||
| env: Mapping[str, str] | None = None, | ||
| ) -> subprocess.CompletedProcess: | ||
| """Run a ``gh`` CLI command and return the completed process.""" | ||
| cmd = ["gh"] + args | ||
| try: | ||
| return run_cmd(cmd, capture_output=capture_output) | ||
| return run_cmd(cmd, capture_output=capture_output, env=env) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, what is the cmd? I would rename the cmd, the env here as well. |
||
| except FileNotFoundError as exc: | ||
| logging.error("gh CLI not found. Install and authenticate gh.") | ||
| raise SystemExit(1) from exc | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -21,6 +21,8 @@ | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import json | ||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||
| from collections.abc import Mapping | ||||||||||||||||||||||||
| from dataclasses import dataclass | ||||||||||||||||||||||||
| from typing import Any | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -50,11 +52,21 @@ class ProjectPriorityField: | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| def _run_graphql(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any] | None: | ||||||||||||||||||||||||
| """Execute a GraphQL query via ``gh api graphql`` and return parsed JSON.""" | ||||||||||||||||||||||||
| """Execute a GraphQL query via ``gh api graphql`` and return parsed JSON. | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| When ``GH_PROJECT_ONLY_TOKEN`` is set in the environment the GraphQL call is made | ||||||||||||||||||||||||
| with that token instead of the default ``GH_TOKEN``. This allows cross-org | ||||||||||||||||||||||||
| project access while the rest of the pipeline continues to use the scoped | ||||||||||||||||||||||||
| ``github.token``. | ||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||
|
Comment on lines
+55
to
+61
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||||||||||||||||
| args = ["api", "graphql", "-f", f"query={query}"] | ||||||||||||||||||||||||
| for k, v in (variables or {}).items(): | ||||||||||||||||||||||||
| args += ["-F", f"{k}={v}"] | ||||||||||||||||||||||||
| res = run_gh(args) | ||||||||||||||||||||||||
| env: Mapping[str, str] | None = None | ||||||||||||||||||||||||
| project_token = os.environ.get("GH_PROJECT_ONLY_TOKEN", "") | ||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is some specific token, idea is to have GH_PROJECT_ONLY_TOKEN (hope renamed) will be 1:1 with this current project_token. It is specific project token for this situation. |
||||||||||||||||||||||||
| if project_token: | ||||||||||||||||||||||||
| env = {**os.environ, "GH_TOKEN": project_token} | ||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If this is the specific env for some scenario, we should rename this env. |
||||||||||||||||||||||||
| res = run_gh(args, env=env) | ||||||||||||||||||||||||
| if res.returncode != 0: | ||||||||||||||||||||||||
| logging.warning(f"GraphQL call failed: {res.stderr}") | ||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not a big fan of the naming, what only stands for in this case? What about ORG_PROJECT_PAT,CROSS_ORG_PROJECT_TOKEN or something like this. Then in the description I would capitalize the word ONLY, to catch an eye.