Skip to main content

License policies

A license policy lets a team (or the whole organization) customise how SPDX license identifiers map to the risk categories allowed, conditional, and forbidden. Without a policy the portal classifies licenses against a fixed, built-in catalog. A policy makes that classification data you can edit at runtime — no redeploy.

Status

The policy data model + CRUD API, the dynamic build-gate evaluation (including hardened compound-SPDX resolution), and the in-app editor are now wired: an effective, enabled policy changes the gate's forbidden-license verdict for that team. See Editing policies in the portal and Dynamic gate evaluation below.

Scopes​

A policy exists at exactly one of two kinds of scope:

Scopeteam_idApplies toWho can write
Groupsetthat one group (team)team_admin of the group, or super_admin
Org defaultnullevery group in the org with no applicable group policysuper_admin only

At most one org-default policy exists per organization, and at most one policy per group. Re-PUTting a scope updates the existing row (idempotent upsert).

Effective policy resolution​

Groups nest without limit: a team can sit under a division, which sits under another division, and so on up to the organization's root. A team's ancestor chain is itself plus every group above it, nearest first, ending at the root. The effective policy for a team is resolved by walking that chain:

  1. the team's own policy, if present and enabled, else
  2. its nearest ancestor group's policy, if present and enabled, else
  3. the next ancestor up, and so on until the root, else
  4. the org-default policy, if present and enabled, else
  5. nothing (the team falls back to the built-in static catalog).

The deepest (nearest to the team) enabled policy in the chain wins wholesale; resolution does not merge fields from two different scopes. A disabled or absent policy at one level simply falls through to the next ancestor; it does not stop the walk or fall straight to the org default. For a deployment that never nests a group below the organization's root, this is exactly the old two-tier "team, then org" behaviour, because such a team's chain has only one entry before the org default.

Setting enabled: false disables a policy without deleting it, so a group can turn dynamic policy off and back on without re-authoring it. The chain then simply continues to whichever ancestor (or the org default) is next.

Policy fields​

FieldTypeMeaning
namestring | nullDisplay label for the UI.
category_overridesobjectSPDX id → allowed | conditional | forbidden. Replaces the catalog verdict for that exact id.
license_exceptionsarrayExplicit waivers — each forces the matched license to allowed.
malicious_exceptionsarrayTime-boxed waivers for packages the malicious snapshot flags — gate count only.
unknown_license_categoryenumPosture for licenses absent from the catalog and the override map. Default conditional.
compound_operator_strategyobjectHow a compound SPDX expression (A AND B, A OR B, A WITH exc) is resolved.
enabledboolMaster toggle. false → policy ignored during resolution.

category_overrides​

{
"MPL-2.0": "forbidden",
"EPL-2.0": "conditional",
"MIT": "allowed"
}

license_exceptions​

Each entry needs spdx_id and reason. expires_at (RFC 3339, optional) lets the gate treat a waiver as expired; component_purl (optional) scopes the waiver to a single component instead of every component carrying the license.

[
{
"spdx_id": "GPL-3.0-only",
"reason": "legal-approved waiver TICKET-123",
"expires_at": "2026-12-31T00:00:00Z",
"component_purl": "pkg:pypi/somepkg@1.2.3"
}
]

malicious_exceptions​

Each entry needs component_purl, reason and expires_at — the expiry is required here, unlike a licence waiver. A licence waiver can reasonably be permanent: counsel cleared the dependency and the answer will not change. A malicious flag always resolves — either the advisory is wrong, in which case challenging it upstream drops the package from the next snapshot, or it is right, in which case the package has to go. An open-ended waiver would only park that decision out of sight.

[
{
"component_purl": "pkg:npm/some-package",
"reason": "advisory challenged upstream, TICKET-456",
"expires_at": "2026-08-20T00:00:00Z"
}
]

The waiver removes the component from the build gate count only. Its badge, its place in the ?malicious=true filter and its drawer row are unchanged — this defers a block while you sort the advisory out, it does not retract the finding. Matching is on the base purl, so an entry pasted with a version still applies.

compound_operator_strategy​

{
"AND": "most_restrictive",
"OR": "least_restrictive",
"WITH": "most_restrictive"
}

Values are most_restrictive or least_restrictive. The default keeps the most restrictive sub-license for AND / WITH, and the least restrictive for OR (the usual reading of a dual-licensed dependency). A partial object is merged with the defaults — you only send the operators you want to change.

Editing policies in the portal​

The Policies screen (sidebar → Policies, route /policies) is the no-code way to author a policy — it drives the same REST API documented below.

  1. Pick a scope. Use the Team picker in the toolbar and click Edit team policy, or click any row in the policies table to edit that scope. A super_admin additionally sees Edit org default for the organization-wide fallback. The selected scope is encoded in the URL (/policies?policy=team:<id> / ?policy=org:<id>), so a bookmark or a hard reload reopens the same editor.
  2. Edit in the drawer. The editor slides in from the right and exposes every field:
    • Policy enabled — master toggle. Off ⇒ the team falls back to the org default or the static catalog without losing the policy's contents.
    • Name — an optional display label.
    • Uncatalogued license posture — the category for a license in neither the catalog nor the overrides.
    • Compound expression strategy — the AND / OR / WITH resolution.
    • Category overrides — add / edit / remove rows mapping an SPDX id to a category.
    • License exceptions — add / remove waivers (SPDX id + reason required; optional expiry date and component PURL).
  3. Save or reset. Save policy issues the upsert PUT; Reset to default (team scope only) deletes the team policy via DELETE, reverting to the org default / static catalog. Validation failures from the server (oversized maps, bad identifiers) surface as an error toast.

Who can edit​

RoleTeam policyOrg default
super_adminany teamyes
team_admin (of the team)that teamno
team member (not admin)read-onlyno

A team member who is not a team_admin can open the editor to view the effective policy, but every control is disabled and a read-only notice is shown — the underlying read returns 403, and the UI degrades gracefully.

Dynamic gate evaluation​

The build-blocking gate (see CI integration) blocks a build when a project has at least one forbidden-licensed component. With no effective policy, "forbidden" means the license category the scanner persisted at scan time against the built-in catalog — behaviour is unchanged.

When the project's owning team has an effective, enabled policy, the gate re-classifies each component's license expression dynamically before counting:

  1. Each component's stored SPDX expression is parsed by a hardened compound-SPDX evaluator (single id, A AND B, A OR B, A WITH exc, parentheses, nesting).
  2. Each operand id is resolved through the policy in order: a matching, non-expired exception (forces allowed) → an explicit override → the built-in catalog → the unknown_license_category posture for anything uncatalogued.
  3. Operands are folded with the per-operator compound_operator_strategy (AND/WITH most-restrictive, OR least-restrictive by default).
  4. A component whose expression resolves to forbidden is counted; a positive count fails the gate.

So a team can, for example, forbid a normally-allowed license, waive a normally-forbidden one for a single dependency, or read a dual-license A OR B permissively — all without a redeploy. Disabling the policy (enabled: false) or deleting it reverts the gate to the static catalog.

Robustness​

License expressions come from scanner output and dependency metadata — untrusted input. The evaluator is bounded and fails safe: it never hangs and never errors out the gate. An expression that is too long, nested too deeply, has too many tokens, is unbalanced, or contains control characters is not parsed; the component is treated with the policy's unknown_license_category posture and a warning is logged. The bounds are: max 4096 characters, max 64 parenthesis nesting levels, max 1024 tokens.

API​

All endpoints are rooted at /v1/license-policies, require a JWT, and return RFC 7807 application/problem+json on any error.

MethodPathAuthPurpose
PUT/v1/license-policies/teams/{team_id}team_adminCreate / update a team policy.
GET/v1/license-policies/teams/{team_id}team memberRead the effective policy for the team.
DELETE/v1/license-policies/teams/{team_id}team_adminReset (delete) the team policy.
PUT/v1/license-policies/org/{organization_id}super_adminCreate / update the org-default policy.
GET/v1/license-policies/org/{organization_id}super_adminRead the org-default policy.
GET/v1/license-policiesauthenticatedPaginated list of visible policies.

The team GET returns the effective policy (team override, else org default) and 404s when neither applies — that 404 means "no policy, falls back to the static catalog", not an error. The org endpoints are super-admin only and existence-hide (a non-super-admin sees 404).

Example​

curl -X PUT https://<portal>/v1/license-policies/teams/$TEAM_ID \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Engineering policy",
"category_overrides": {"MPL-2.0": "forbidden"},
"license_exceptions": [
{"spdx_id": "GPL-3.0-only", "reason": "legal waiver TICKET-123"}
],
"unknown_license_category": "conditional",
"enabled": true
}'

The full request / response schemas (with examples) are in the live OpenAPI document at /api/docs. See also the license classification table for the built-in catalog the policy overrides.