From 49f6380fa4bba56597406db06ab2e3fe2ccccc82 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Thu, 25 Jun 2026 09:22:15 -0500 Subject: [PATCH] feat: add licensing API token scope (#697) Add read:licensing / write:licensing token scope category so licensing endpoints are guarded by the same permission system as all other API endpoints. Public-only tokens are rejected for licensing endpoints. --- CHANGELOG.md | 1 + models/auth/access_token_scope.go | 18 ++++++++++++++++-- models/auth/access_token_scope_test.go | 6 +++--- routers/api/v1/api.go | 5 ++++- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f75def19ba..a02f476606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] ### Added +- API token scope `read:licensing` / `write:licensing` for licensing endpoints (#697) - Wiki full-text search: case-insensitive search across all wiki page titles and content (#550) - Wiki search API: GET /wiki/search?q=term with paginated JSON results (#550) - Metadata deploy fields: deploy_host, deploy_port, deploy_user, deploy_path, docker_image, docker_registry, container_name, health_url (#692) diff --git a/models/auth/access_token_scope.go b/models/auth/access_token_scope.go index 4306b0d520..d54ac4b712 100644 --- a/models/auth/access_token_scope.go +++ b/models/auth/access_token_scope.go @@ -24,6 +24,7 @@ const ( AccessTokenScopeCategoryIssue AccessTokenScopeCategoryRepository AccessTokenScopeCategoryUser + AccessTokenScopeCategoryLicensing ) // AllAccessTokenScopeCategories contains all access token scope categories @@ -37,6 +38,7 @@ var AllAccessTokenScopeCategories = []AccessTokenScopeCategory{ AccessTokenScopeCategoryIssue, AccessTokenScopeCategoryRepository, AccessTokenScopeCategoryUser, + AccessTokenScopeCategoryLicensing, } // AccessTokenScopeLevel represents the access levels without a given scope category @@ -82,6 +84,9 @@ const ( AccessTokenScopeReadUser AccessTokenScope = "read:user" AccessTokenScopeWriteUser AccessTokenScope = "write:user" + + AccessTokenScopeReadLicensing AccessTokenScope = "read:licensing" + AccessTokenScopeWriteLicensing AccessTokenScope = "write:licensing" ) // accessTokenScopeBitmap represents a bitmap of access token scopes. @@ -93,7 +98,8 @@ const ( accessTokenScopeAllBits accessTokenScopeBitmap = accessTokenScopeWriteActivityPubBits | accessTokenScopeWriteAdminBits | accessTokenScopeWriteMiscBits | accessTokenScopeWriteNotificationBits | accessTokenScopeWriteOrganizationBits | accessTokenScopeWritePackageBits | accessTokenScopeWriteIssueBits | - accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits + accessTokenScopeWriteRepositoryBits | accessTokenScopeWriteUserBits | + accessTokenScopeWriteLicensingBits accessTokenScopePublicOnlyBits accessTokenScopeBitmap = 1 << iota @@ -124,6 +130,9 @@ const ( accessTokenScopeReadUserBits accessTokenScopeBitmap = 1 << iota accessTokenScopeWriteUserBits accessTokenScopeBitmap = 1< 64 scopes, // refactoring the whole implementation in this file (and only this file) is needed. @@ -142,6 +151,7 @@ var allAccessTokenScopes = []AccessTokenScope{ AccessTokenScopeWriteIssue, AccessTokenScopeReadIssue, AccessTokenScopeWriteRepository, AccessTokenScopeReadRepository, AccessTokenScopeWriteUser, AccessTokenScopeReadUser, + AccessTokenScopeWriteLicensing, AccessTokenScopeReadLicensing, } // allAccessTokenScopeBits contains all access token scopes. @@ -166,6 +176,8 @@ var allAccessTokenScopeBits = map[AccessTokenScope]accessTokenScopeBitmap{ AccessTokenScopeWriteRepository: accessTokenScopeWriteRepositoryBits, AccessTokenScopeReadUser: accessTokenScopeReadUserBits, AccessTokenScopeWriteUser: accessTokenScopeWriteUserBits, + AccessTokenScopeReadLicensing: accessTokenScopeReadLicensingBits, + AccessTokenScopeWriteLicensing: accessTokenScopeWriteLicensingBits, } // readAccessTokenScopes maps a scope category to the read permission scope @@ -180,6 +192,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeReadIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeReadRepository, AccessTokenScopeCategoryUser: AccessTokenScopeReadUser, + AccessTokenScopeCategoryLicensing: AccessTokenScopeReadLicensing, }, Write: { AccessTokenScopeCategoryActivityPub: AccessTokenScopeWriteActivityPub, @@ -191,6 +204,7 @@ var accessTokenScopes = map[AccessTokenScopeLevel]map[AccessTokenScopeCategory]A AccessTokenScopeCategoryIssue: AccessTokenScopeWriteIssue, AccessTokenScopeCategoryRepository: AccessTokenScopeWriteRepository, AccessTokenScopeCategoryUser: AccessTokenScopeWriteUser, + AccessTokenScopeCategoryLicensing: AccessTokenScopeWriteLicensing, }, } @@ -370,7 +384,7 @@ func (bitmap accessTokenScopeBitmap) toScope() AccessTokenScope { scope := AccessTokenScope(strings.Join(scopes, ",")) scope = AccessTokenScope(strings.ReplaceAll( string(scope), - "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", + "write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing", "all", )) return scope diff --git a/models/auth/access_token_scope_test.go b/models/auth/access_token_scope_test.go index b93c25528f..36aacf151f 100644 --- a/models/auth/access_token_scope_test.go +++ b/models/auth/access_token_scope_test.go @@ -17,13 +17,13 @@ type scopeTestNormalize struct { } func TestAccessTokenScope_Normalize(t *testing.T) { - assert.Equal(t, []string{"activitypub", "admin", "issue", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories()) + assert.Equal(t, []string{"activitypub", "admin", "issue", "licensing", "misc", "notification", "organization", "package", "repository", "user"}, GetAccessTokenCategories()) tests := []scopeTestNormalize{ {"", "", nil}, {"write:misc,write:notification,read:package,write:notification,public-only", "public-only,write:misc,write:notification,read:package", nil}, {"all", "all", nil}, - {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user", "all", nil}, - {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,public-only", "public-only,all", nil}, + {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing", "all", nil}, + {"write:activitypub,write:admin,write:misc,write:notification,write:organization,write:package,write:issue,write:repository,write:user,write:licensing,public-only", "public-only,all", nil}, } for _, scope := range GetAccessTokenCategories() { diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 61e2ce66ba..c913ef78f9 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -294,6 +294,9 @@ func checkTokenPublicOnly() func(ctx *context.APIContext) { ctx.APIError(http.StatusForbidden, "token scope is limited to public packages") return } + case auth_model.AccessTokenScopeCategoryLicensing: + ctx.APIError(http.StatusForbidden, "token scope is limited to public resources, licensing is not available") + return } } } @@ -1892,7 +1895,7 @@ func Routes() *web.Router { // Authenticated license detail m.Get("/{dlid}/status", reqToken(), licensing.Status) - }) + }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryLicensing)) }, sudo()) return m -- 2.52.0