Skip to content

Commit cf94dbe

Browse files
reggiCopilot
andauthored
feat: add permissions support to trust commands (#9248)
## Summary Adds permission flags to trust create operations. Users must now specify at least one of `--allow-publish` or `--allow-stage-publish` (alias: `--allow-staged-publish`) when creating trust configurations. ## Changes - Add `--allow-publish` and `--allow-stage-publish` flags to all trust provider commands (GitHub, GitLab, CircleCI) - Require at least one permission flag when creating trust configurations - Include permissions in the request body and display output - Add `PERMISSIONS` constants for permission values - Update tests and completion snapshots for new flags ## Related - #9201 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent c97b39b commit cf94dbe

11 files changed

Lines changed: 241 additions & 42 deletions

File tree

docs/lib/content/commands/npm-trust.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,17 @@ The `[package]` argument specifies the package name. If omitted, npm will use th
2828

2929
Each trust relationship has its own set of configuration options and flags based on the OIDC claims provided by that provider. OIDC claims come from the CI/CD provider and include information such as repository name, workflow file, or environment. Since each provider's claims differ, the available flags and configuration keys are not universal—npm matches the claims supported by each provider's OIDC configuration. For specific details on which claims and flags are supported for a given provider, use `npm trust <provider> --help`.
3030

31+
### Permissions
32+
33+
When creating a trust relationship, you must specify at least one permission flag to indicate which operations the trusted publisher is allowed to perform:
34+
35+
* `--allow-publish`: Allows the trusted publisher to run `npm publish` for the package.
36+
* `--allow-stage-publish`: Allows the trusted publisher to run `npm stage` for the package. The alias `--allow-staged-publish` is also accepted.
37+
38+
At least one of these flags is required when creating a trust configuration. You can specify both to grant both permissions.
39+
40+
### Provider Options
41+
3142
The required options depend on the CI/CD provider you're configuring. Detailed information about each option is available in the [managing trusted publisher configurations](https://docs.npmjs.com/trusted-publishers#managing-trusted-publisher-configurations) section of the npm documentation. If a provider is repository-based and the option is not provided, npm will use the `repository.url` field from your `package.json`, if available.
3243

3344
Currently, the registry only supports one configuration per package. If you attempt to create a new trust relationship when one already exists, it will result in an error. To replace an existing configuration:

lib/commands/trust/circleci.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const Definition = require('@npmcli/config/lib/definitions/definition.js')
22
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
33
const TrustCommand = require('../../trust-cmd.js')
4+
const { trustDefinitions } = require('../../trust-cmd.js')
45

56
// UUID validation regex
67
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
@@ -13,7 +14,7 @@ class TrustCircleCI extends TrustCommand {
1314
static providerEntity = 'CircleCI pipeline'
1415

1516
static usage = [
16-
'[package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [-y|--yes]',
17+
'[package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
1718
]
1819

1920
static definitions = [
@@ -46,6 +47,8 @@ class TrustCircleCI extends TrustCommand {
4647
type: [null, String, Array],
4748
description: 'CircleCI context UUID to match',
4849
}),
50+
trustDefinitions['allow-publish'],
51+
trustDefinitions['allow-stage-publish'],
4952
// globals are alphabetical
5053
globalDefinitions['dry-run'],
5154
globalDefinitions.json,

lib/commands/trust/github.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const Definition = require('@npmcli/config/lib/definitions/definition.js')
22
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
33
const TrustCommand = require('../../trust-cmd.js')
4+
const { trustDefinitions } = require('../../trust-cmd.js')
45
const path = require('node:path')
56

67
class TrustGitHub extends TrustCommand {
@@ -16,7 +17,7 @@ class TrustGitHub extends TrustCommand {
1617
static entityKey = 'repository'
1718

1819
static usage = [
19-
'[package] --file [--repo|--repository] [--env|--environment] [-y|--yes]',
20+
'[package] --file [--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
2021
]
2122

2223
static definitions = [
@@ -38,6 +39,8 @@ class TrustGitHub extends TrustCommand {
3839
description: 'CI environment name',
3940
alias: ['env'],
4041
}),
42+
trustDefinitions['allow-publish'],
43+
trustDefinitions['allow-stage-publish'],
4144
// globals are alphabetical
4245
globalDefinitions['dry-run'],
4346
globalDefinitions.json,

lib/commands/trust/gitlab.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
const Definition = require('@npmcli/config/lib/definitions/definition.js')
22
const globalDefinitions = require('@npmcli/config/lib/definitions/definitions.js')
33
const TrustCommand = require('../../trust-cmd.js')
4+
const { trustDefinitions } = require('../../trust-cmd.js')
45
const path = require('node:path')
56

67
class TrustGitLab extends TrustCommand {
@@ -16,7 +17,7 @@ class TrustGitLab extends TrustCommand {
1617
static entityKey = 'project'
1718

1819
static usage = [
19-
'[package] --file [--project|--repo|--repository] [--env|--environment] [-y|--yes]',
20+
'[package] --file [--project|--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]',
2021
]
2122

2223
static definitions = [
@@ -37,6 +38,8 @@ class TrustGitLab extends TrustCommand {
3738
description: 'CI environment name',
3839
alias: ['env'],
3940
}),
41+
trustDefinitions['allow-publish'],
42+
trustDefinitions['allow-stage-publish'],
4043
// globals are alphabetical
4144
globalDefinitions['dry-run'],
4245
globalDefinitions.json,

lib/trust-cmd.js

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,29 @@ const { read: _read } = require('read')
66
const { input, output, log, META } = require('proc-log')
77
const gitinfo = require('hosted-git-info')
88
const pkgJson = require('@npmcli/package-json')
9+
const Definition = require('@npmcli/config/lib/definitions/definition.js')
910

1011
const NPM_FRONTEND = 'https://www.npmjs.com'
1112

13+
const PERMISSIONS = {
14+
CREATE_PACKAGE: 'createPackage',
15+
CREATE_STAGED_PACKAGE: 'createStagedPackage',
16+
}
17+
18+
const trustDefinitions = {
19+
'allow-publish': new Definition('allow-publish', {
20+
default: false,
21+
type: Boolean,
22+
description: 'Allow npm publish for this trusted publisher configuration',
23+
}),
24+
'allow-stage-publish': new Definition('allow-stage-publish', {
25+
default: false,
26+
type: Boolean,
27+
description: 'Allow npm stage publish for this trusted publisher configuration',
28+
alias: ['allow-staged-publish'],
29+
}),
30+
}
31+
1232
class TrustCommand extends BaseCommand {
1333
// Helper to format template strings with color
1434
// Blue text with reset color for interpolated values
@@ -45,8 +65,22 @@ class TrustCommand extends BaseCommand {
4565
}))
4666
}
4767

68+
static permissionLabels = {
69+
[PERMISSIONS.CREATE_PACKAGE]: 'publish',
70+
[PERMISSIONS.CREATE_STAGED_PACKAGE]: 'stage publish',
71+
}
72+
73+
static formatPermissions (permissions) {
74+
if (!Array.isArray(permissions) || permissions.length === 0) {
75+
return null
76+
}
77+
return permissions
78+
.map(p => TrustCommand.permissionLabels[p] || p)
79+
.join(', ')
80+
}
81+
4882
logOptions (options, pad = true) {
49-
const { values, warnings, fromPackageJson, urls } = { warnings: [], ...options }
83+
const { values, warnings, fromPackageJson, urls, permissions } = { warnings: [], ...options }
5084
if (warnings && warnings.length > 0) {
5185
for (const warningMsg of warnings) {
5286
log.warn('trust', warningMsg)
@@ -55,8 +89,12 @@ class TrustCommand extends BaseCommand {
5589

5690
const json = this.config.get('json')
5791
if (json) {
92+
const jsonValues = { ...options.values }
93+
if (permissions) {
94+
jsonValues.permissions = permissions
95+
}
5896
// Disable redaction: trust config values (e.g. CircleCI UUIDs) are not secrets
59-
output.standard(JSON.stringify(options.values, null, 2), { [META]: true, redact: false })
97+
output.standard(JSON.stringify(jsonValues, null, 2), { [META]: true, redact: false })
6098
return
6199
}
62100

@@ -82,6 +120,10 @@ class TrustCommand extends BaseCommand {
82120
lines.push(parts.join(' '))
83121
}
84122
}
123+
const formattedPermissions = TrustCommand.formatPermissions(permissions)
124+
if (formattedPermissions) {
125+
lines.push(`${chalk.reset('permissions')}: ${chalk.green(formattedPermissions)}`)
126+
}
85127
if (pad) {
86128
output.standard()
87129
}
@@ -165,19 +207,36 @@ class TrustCommand extends BaseCommand {
165207
const { providerName, providerEntity, providerHostname } = this.constructor
166208
const dryRun = this.config.get('dry-run')
167209
const yes = this.config.get('yes') // deep-lore this allows for --no-yes
210+
211+
const allowPublish = flags['allow-publish']
212+
const allowStagePublish = flags['allow-stage-publish']
213+
214+
if (!allowPublish && !allowStagePublish) {
215+
throw new Error('At least one permission flag is required (--allow-publish, --allow-stage-publish)')
216+
}
217+
218+
const permissions = []
219+
if (allowPublish) {
220+
permissions.push(PERMISSIONS.CREATE_PACKAGE)
221+
}
222+
if (allowStagePublish) {
223+
permissions.push(PERMISSIONS.CREATE_STAGED_PACKAGE)
224+
}
225+
168226
const options = await this.flagsToOptions({ positionalArgs, flags, providerHostname })
169227
this.dialogue`Establishing trust between ${options.values.package} package and ${providerName}`
170228
this.dialogue`Anyone with ${providerEntity} write access can publish to ${options.values.package}`
171229
this.dialogue`Two-factor authentication is required for this operation`
172230
if (!this.registryIsDefault) {
173231
this.warn`Registry ${this.npm.config.get('registry')} may not support trusted publishing`
174232
}
175-
this.logOptions(options)
233+
this.logOptions({ ...options, permissions })
176234
if (dryRun) {
177235
return
178236
}
179237
await this.confirmOperation(yes)
180238
const trustConfig = this.constructor.optionsToBody(options.values)
239+
trustConfig.permissions = permissions
181240
const response = await this.createConfig(options.values.package, [trustConfig])
182241
const body = await response.json()
183242
this.dialogue`Trust configuration created successfully for ${options.values.package} with the following settings:`
@@ -273,12 +332,14 @@ class TrustCommand extends BaseCommand {
273332
const items = Array.isArray(body) ? body : [body]
274333
for (const config of items) {
275334
const values = this.constructor.bodyToOptions(config)
335+
const permissions = config.permissions
276336
output.standard()
277-
this.logOptions({ values }, false)
337+
this.logOptions({ values, permissions }, false)
278338
}
279339
output.standard()
280340
}
281341
}
282342

283343
module.exports = TrustCommand
284344
module.exports.NPM_FRONTEND = NPM_FRONTEND
345+
module.exports.trustDefinitions = trustDefinitions

tap-snapshots/test/lib/commands/completion.js.test.cjs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,16 @@ Array [
103103
--repo
104104
--environment
105105
--env
106+
--allow-publish
107+
--allow-stage-publish
108+
--allow-staged-publish
106109
--dry-run
107110
--json
108111
--registry
109112
--yes
113+
--no-allow-publish
114+
--no-allow-stage-publish
115+
--no-allow-staged-publish
110116
--no-dry-run
111117
--no-json
112118
--no-yes
@@ -121,10 +127,16 @@ Array [
121127
--project
122128
--environment
123129
--env
130+
--allow-publish
131+
--allow-stage-publish
132+
--allow-staged-publish
124133
--dry-run
125134
--json
126135
--registry
127136
--yes
137+
--no-allow-publish
138+
--no-allow-stage-publish
139+
--no-allow-staged-publish
128140
--no-dry-run
129141
--no-json
130142
--no-yes

tap-snapshots/test/lib/docs.js.test.cjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5821,9 +5821,9 @@ exports[`test/lib/docs.js TAP usage trust > must match snapshot 1`] = `
58215821
Create a trusted relationship between a package and a OIDC provider
58225822
58235823
Usage:
5824-
npm trust github [package] --file [--repo|--repository] [--env|--environment] [-y|--yes]
5825-
npm trust gitlab [package] --file [--project|--repo|--repository] [--env|--environment] [-y|--yes]
5826-
npm trust circleci [package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [-y|--yes]
5824+
npm trust github [package] --file [--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]
5825+
npm trust gitlab [package] --file [--project|--repo|--repository] [--env|--environment] [--allow-publish] [--allow-stage-publish] [-y|--yes]
5826+
npm trust circleci [package] --org-id <uuid> --project-id <uuid> --pipeline-definition-id <uuid> --vcs-origin <origin> [--context-id <uuid>...] [--allow-publish] [--allow-stage-publish] [-y|--yes]
58275827
npm trust list [package]
58285828
npm trust revoke [package] --id=<trust-id>
58295829

test/lib/commands/trust/circleci.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ t.test('circleci with all options provided', async t => {
4444
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
4545
'--vcs-origin', 'github.com/owner/repo',
4646
'--context-id', '123e4567-e89b-12d3-a456-426614174000',
47+
'--allow-publish',
4748
])
4849
})
4950

@@ -85,6 +86,7 @@ t.test('circleci without optional context-id', async t => {
8586
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
8687
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
8788
'--vcs-origin', 'github.com/owner/repo',
89+
'--allow-publish',
8890
])
8991
})
9092

@@ -128,6 +130,7 @@ t.test('circleci with multiple context-ids', async t => {
128130
'--vcs-origin', 'github.com/owner/repo',
129131
'--context-id', '123e4567-e89b-12d3-a456-426614174000',
130132
'--context-id', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
133+
'--allow-publish',
131134
])
132135
})
133136

@@ -152,6 +155,7 @@ t.test('circleci missing required org-id', async t => {
152155
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
153156
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
154157
'--vcs-origin', 'github.com/owner/repo',
158+
'--allow-publish',
155159
]),
156160
{ message: /org-id is required/ }
157161
)
@@ -178,6 +182,7 @@ t.test('circleci missing required project-id', async t => {
178182
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
179183
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
180184
'--vcs-origin', 'github.com/owner/repo',
185+
'--allow-publish',
181186
]),
182187
{ message: /project-id is required/ }
183188
)
@@ -204,6 +209,7 @@ t.test('circleci missing required pipeline-definition-id', async t => {
204209
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
205210
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
206211
'--vcs-origin', 'github.com/owner/repo',
212+
'--allow-publish',
207213
]),
208214
{ message: /pipeline-definition-id is required/ }
209215
)
@@ -230,6 +236,7 @@ t.test('circleci missing required vcs-origin', async t => {
230236
'--org-id', '550e8400-e29b-41d4-a716-446655440000',
231237
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
232238
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
239+
'--allow-publish',
233240
]),
234241
{ message: /vcs-origin is required/ }
235242
)
@@ -257,6 +264,7 @@ t.test('circleci with invalid org-id uuid format', async t => {
257264
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
258265
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
259266
'--vcs-origin', 'github.com/owner/repo',
267+
'--allow-publish',
260268
]),
261269
{ message: /org-id must be a valid UUID/ }
262270
)
@@ -284,6 +292,7 @@ t.test('circleci with invalid vcs-origin format', async t => {
284292
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
285293
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
286294
'--vcs-origin', 'invalid-format',
295+
'--allow-publish',
287296
]),
288297
{ message: /vcs-origin must be in format 'provider\/owner\/repo'/ }
289298
)
@@ -311,6 +320,7 @@ t.test('circleci with vcs-origin containing scheme prefix', async t => {
311320
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
312321
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
313322
'--vcs-origin', 'https://github.com/owner/repo',
323+
'--allow-publish',
314324
]),
315325
{ message: /vcs-origin must not include a scheme/ }
316326
)
@@ -336,6 +346,7 @@ t.test('circleci missing package name', async t => {
336346
'--project-id', '7c9e6679-7425-40de-944b-e07fc1f90ae7',
337347
'--pipeline-definition-id', '6ba7b810-9dad-11d1-80b4-00c04fd430c8',
338348
'--vcs-origin', 'github.com/owner/repo',
349+
'--allow-publish',
339350
]),
340351
{ message: /Package name must be specified either as an argument or in package.json file/ }
341352
)

test/lib/commands/trust/github.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ t.test('github with all options provided', async t => {
3535

3636
registry.trustCreate({ packageName })
3737

38-
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production'])
38+
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--environment', 'production', '--allow-publish'])
3939
})
4040

4141
t.test('github with invalid repository format', async t => {
@@ -61,7 +61,7 @@ t.test('github with invalid repository format', async t => {
6161
})
6262

6363
await t.rejects(
64-
npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'invalid']),
64+
npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'invalid', '--allow-publish']),
6565
{ message: /must be specified in the format owner\/repository/ }
6666
)
6767
})
@@ -89,7 +89,7 @@ t.test('github with file as path', async t => {
8989
})
9090

9191
await t.rejects(
92-
npm.exec('trust', ['github', packageName, '--yes', '--file', '.github/workflows/ci.yml', '--repository', 'owner/repo']),
92+
npm.exec('trust', ['github', packageName, '--yes', '--file', '.github/workflows/ci.yml', '--repository', 'owner/repo', '--allow-publish']),
9393
{ message: /must be just a file not a path/ }
9494
)
9595
})
@@ -124,7 +124,7 @@ t.test('github without environment', async t => {
124124

125125
registry.trustCreate({ packageName })
126126

127-
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo'])
127+
await npm.exec('trust', ['github', packageName, '--yes', '--file', 'workflow.yml', '--repository', 'owner/repo', '--allow-publish'])
128128
})
129129

130130
t.test('bodyToOptions with all fields', t => {

0 commit comments

Comments
 (0)