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.
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:
| Scope | team_id | Applies to | Who can write |
|---|---|---|---|
| Group | set | that one group (team) | team_admin of the group, or super_admin |
| Org default | null | every group in the org with no applicable group policy | super_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:
- the team's own policy, if present and enabled, else
- its nearest ancestor group's policy, if present and enabled, else
- the next ancestor up, and so on until the root, else
- the org-default policy, if present and enabled, else
- 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
| Field | Type | Meaning |
|---|---|---|
name | string | null | Display label for the UI. |
category_overrides | object | SPDX id → allowed | conditional | forbidden. Replaces the catalog verdict for that exact id. |
license_exceptions | array | Explicit waivers — each forces the matched license to allowed. |
malicious_exceptions | array | Time-boxed waivers for packages the malicious snapshot flags — gate count only. |
unknown_license_category | enum | Posture for licenses absent from the catalog and the override map. Default conditional. |
compound_operator_strategy | object | How a compound SPDX expression (A AND B, A OR B, A WITH exc) is resolved. |
enabled | bool | Master 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.
- 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_adminadditionally 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. - 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/WITHresolution. - 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).
- Save or reset. Save policy issues the upsert
PUT; Reset to default (team scope only) deletes the team policy viaDELETE, reverting to the org default / static catalog. Validation failures from the server (oversized maps, bad identifiers) surface as an error toast.
Who can edit
| Role | Team policy | Org default |
|---|---|---|
super_admin | any team | yes |
team_admin (of the team) | that team | no |
| team member (not admin) | read-only | no |
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:
- 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). - Each operand id is resolved through the policy in order: a matching,
non-expired exception (forces
allowed) → an explicit override → the built-in catalog → theunknown_license_categoryposture for anything uncatalogued. - Operands are folded with the per-operator
compound_operator_strategy(AND/WITHmost-restrictive,ORleast-restrictive by default). - A component whose expression resolves to
forbiddenis 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.
| Method | Path | Auth | Purpose |
|---|---|---|---|
PUT | /v1/license-policies/teams/{team_id} | team_admin | Create / update a team policy. |
GET | /v1/license-policies/teams/{team_id} | team member | Read the effective policy for the team. |
DELETE | /v1/license-policies/teams/{team_id} | team_admin | Reset (delete) the team policy. |
PUT | /v1/license-policies/org/{organization_id} | super_admin | Create / update the org-default policy. |
GET | /v1/license-policies/org/{organization_id} | super_admin | Read the org-default policy. |
GET | /v1/license-policies | authenticated | Paginated 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.