diff --git a/cmd/admin_user_generate_authorized_integration.go b/cmd/admin_user_generate_authorized_integration.go index 05d7bcc07a..262f35161d 100644 --- a/cmd/admin_user_generate_authorized_integration.go +++ b/cmd/admin_user_generate_authorized_integration.go @@ -120,6 +120,7 @@ func runCreateAuthorizedIntegration(ctx context.Context, c *cli.Command) error { UserID: user.ID, Name: c.String("name"), Description: c.String("description"), + UI: auth_model.AuthorizedIntegrationUIGeneric, } var rules []auth_model.ClaimRule diff --git a/models/auth/authorized_integration.go b/models/auth/authorized_integration.go index 61f0725efb..a401757d55 100644 --- a/models/auth/authorized_integration.go +++ b/models/auth/authorized_integration.go @@ -33,6 +33,18 @@ type AuthorizedIntegration struct { Name string // short name for lists of authorized integrations Description string `xorm:"LONGTEXT"` // long description, optional to document relevant details of the integration + // Which UI to use for view/edit of this Authorized Integration. Authorized Integrations' functional behaviour is + // defined by other fields, such as the Issuer, Audience, ClaimRules. The UI field only defines how this record can + // be interacted with by the user in order to provide user-friendly access for specific systems -- like Forgejo + // Actions. Within the potential scope of the UI is any user interaction with the Authorized Integration -- web + // create, read, update, API, CLI. + // + // The UI field must never be used to make functional decisions about evaluating JWTs. It must always be possible + // to convert an Authorized Integration to a "generic" UI (for customization that the UI doesn't support). The + // intent of this design is that, the Authorized Integration system is complicated in its claim rules, but they + // always fully define the behaviour in a transparent manner. + UI AuthorizedIntegrationUI `xorm:"NOT NULL default('generic')"` + // Exact-match `iss` claim of the JWT Issuer string `xorm:"NOT NULL UNIQUE(s)"` // Exact-match `aud` claim of the JWT @@ -128,6 +140,17 @@ const ( ClaimNested ClaimComparison = "nest" // recurse into a claim that is an map[string]any with it's own data fields ) +type AuthorizedIntegrationUI string + +const ( + // Generic UI which allows the user to view and edit claim rules directly to support integrations that Forgejo + // doesn't have a user-friendly UI to support. + AuthorizedIntegrationUIGeneric AuthorizedIntegrationUI = "generic" + + // UI specific to Actions that are running on this local Forgejo instance accessing itself. + AuthorizedIntegrationUIForgejoActionsLocal AuthorizedIntegrationUI = "forgejo-actions-local" +) + func GetAuthorizedIntegration(ctx context.Context, issuer, audience string) (*AuthorizedIntegration, error) { var ai AuthorizedIntegration found, err := db.GetEngine(ctx).Where("issuer = ? AND audience = ?", issuer, audience).Get(&ai) diff --git a/models/forgejo_migrations/v16c_authorized_integration_ui.go b/models/forgejo_migrations/v16c_authorized_integration_ui.go new file mode 100644 index 0000000000..4c5ee8dd5e --- /dev/null +++ b/models/forgejo_migrations/v16c_authorized_integration_ui.go @@ -0,0 +1,27 @@ +// Copyright 2026 The Forgejo Authors. All rights reserved. +// SPDX-License-Identifier: GPL-3.0-or-later + +package forgejo_migrations + +import ( + "xorm.io/xorm" +) + +func init() { + registerMigration(&Migration{ + Description: "add ui to authorized_integration", + Upgrade: addAuthorizedIntegrationUI, + }) +} + +func addAuthorizedIntegrationUI(x *xorm.Engine) error { + type AuthorizedIntegration struct { + UI string `xorm:"NOT NULL default('generic')"` + } + + _, err := x.SyncWithOptions( + xorm.SyncOptions{IgnoreDropIndices: true}, + new(AuthorizedIntegration), + ) + return err +} diff --git a/services/authz/authorized_integration.go b/services/authz/authorized_integration.go index 33099c7401..7f789a09df 100644 --- a/services/authz/authorized_integration.go +++ b/services/authz/authorized_integration.go @@ -5,11 +5,14 @@ package authz import ( "context" + "errors" "fmt" auth_model "forgejo.org/models/auth" ) +var ErrAuthorizedIntegrationBadUI = errors.New("invalid authorized integration UI") + func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *auth_model.AuthorizedIntegration) (AuthorizationReducer, error) { if ai.ResourceAllRepos { if publicOnly, err := ai.Scope.PublicOnly(); err != nil { @@ -35,6 +38,12 @@ func GetAuthorizationReducerForAuthorizedIntegration(ctx context.Context, ai *au // Validate that an authorized integration's state is valid for creation. For example, that it doesn't have a // conflicting set of resources (public-only and specific repositories), and other similar checks. func ValidateAuthorizedIntegration(ai *auth_model.AuthorizedIntegration, repoResources []*auth_model.AuthorizedIntegResourceRepo) error { - // Other validations may be added here in the future. + switch ai.UI { + case auth_model.AuthorizedIntegrationUIGeneric, + auth_model.AuthorizedIntegrationUIForgejoActionsLocal: + break + default: + return fmt.Errorf("%w: invalid UI: %q", ErrAuthorizedIntegrationBadUI, ai.UI) + } return validateRepositoryResource(ai.ResourceAllRepos, ai.Scope, len(repoResources)) } diff --git a/services/authz/authorized_integration_test.go b/services/authz/authorized_integration_test.go index cb46a2479f..787d3560dd 100644 --- a/services/authz/authorized_integration_test.go +++ b/services/authz/authorized_integration_test.go @@ -51,6 +51,7 @@ func TestValidateAuthorizedIntegration(t *testing.T) { ai := &auth.AuthorizedIntegration{ ResourceAllRepos: true, Scope: auth.AccessTokenScopeReadRepository, + UI: auth.AuthorizedIntegrationUIGeneric, } err := ValidateAuthorizedIntegration(ai, nil) require.NoError(t, err) @@ -60,6 +61,7 @@ func TestValidateAuthorizedIntegration(t *testing.T) { ai := &auth.AuthorizedIntegration{ ResourceAllRepos: false, Scope: auth.AccessTokenScopeReadRepository, + UI: auth.AuthorizedIntegrationUIGeneric, } resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}} err := ValidateAuthorizedIntegration(ai, resources) @@ -70,6 +72,7 @@ func TestValidateAuthorizedIntegration(t *testing.T) { ai := &auth.AuthorizedIntegration{ ResourceAllRepos: false, Scope: auth.AccessTokenScopeReadRepository, + UI: auth.AuthorizedIntegrationUIGeneric, } resources := []*auth.AuthorizedIntegResourceRepo{} err := ValidateAuthorizedIntegration(ai, resources) @@ -80,6 +83,7 @@ func TestValidateAuthorizedIntegration(t *testing.T) { ai := &auth.AuthorizedIntegration{ ResourceAllRepos: false, Scope: auth.AccessTokenScope(strings.Join([]string{string(auth.AccessTokenScopePublicOnly), string(auth.AccessTokenScopeReadRepository)}, ",")), + UI: auth.AuthorizedIntegrationUIGeneric, } resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}} err := ValidateAuthorizedIntegration(ai, resources) @@ -90,10 +94,22 @@ func TestValidateAuthorizedIntegration(t *testing.T) { ai := &auth.AuthorizedIntegration{ ResourceAllRepos: false, Scope: auth.AccessTokenScopeReadAdmin, + UI: auth.AuthorizedIntegrationUIGeneric, } resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}} err := ValidateAuthorizedIntegration(ai, resources) require.ErrorIs(t, err, ErrSpecifiedReposInvalidScope) require.ErrorContains(t, err, string(auth.AccessTokenScopeReadAdmin)) }) + + t.Run("invalid - missing UI", func(t *testing.T) { + ai := &auth.AuthorizedIntegration{ + ResourceAllRepos: false, + Scope: auth.AccessTokenScopeReadAdmin, + } + resources := []*auth.AuthorizedIntegResourceRepo{{RepoID: 12}} + err := ValidateAuthorizedIntegration(ai, resources) + require.ErrorIs(t, err, ErrAuthorizedIntegrationBadUI) + require.ErrorContains(t, err, "invalid UI: \"\"") + }) }