merge: hexmove/policies into chore/hexcheck-v0.4.0
18 files · +231 -53
M internal/adapters/kvstore/ci.go +8 -8
| @@ -576,18 +576,14 @@ func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) { | |||
| 576 | 576 | ||
| 577 | 577 | const createRunTries = 20 | |
| 578 | 578 | ||
| 579 | - | // CreateRun takes the next run id and number and stores the draft. | |
| 579 | + | // CreateRun takes the next run id and number and stores the draft. The batch | |
| 580 | + | // requires the workflow's disabled key absent, so a disable that races the | |
| 581 | + | // caller's check fails the batch and is reported as domain.ErrWorkflowDisabled. | |
| 580 | 582 | // | |
| 581 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule rejecting runs of disabled workflows, atomically with creation; pending move to domain/usecase | |
| 583 | + | //hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides | |
| 582 | 584 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 583 | 585 | offKey := workflowOffKey(repo, workflowPath) | |
| 584 | 586 | for range createRunTries { | |
| 585 | - | // A workflow the repository disabled gets no run, whatever started it. | |
| 586 | - | // The key is also required absent by the batch below, so a disable that | |
| 587 | - | // lands after this read fails the batch, and the next try sees it. | |
| 588 | - | if _, off := c.db.Get(ctx, offKey); off { | |
| 589 | - | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 590 | - | } | |
| 591 | 587 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | |
| 592 | 588 | if err != nil { | |
| 593 | 589 | return ports.CIRunDraft{}, err | |
| @@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||
| 620 | 616 | } | |
| 621 | 617 | v, err := c.db.Apply(ctx, &b) | |
| 622 | 618 | if errors.Is(err, kv.ErrConflict) { | |
| 619 | + | // Translate a failed disabled-workflow precondition; other conflicts retry. | |
| 620 | + | if _, off := c.db.Get(ctx, offKey); off { | |
| 621 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 622 | + | } | |
| 623 | 623 | continue | |
| 624 | 624 | } | |
| 625 | 625 | if err != nil { | |
| @@ -576,18 +576,14 @@ func stageSeq(b *kv.Batch, key []byte, item kv.Item, ok bool, last int64) { | |||||
| 576 | 576 | ||||
| 577 | const createRunTries = 20 | 577 | const createRunTries = 20 | ||
| 578 | 578 | ||||
| 579 | - | // CreateRun takes the next run id and number and stores the draft. | 579 | + | // CreateRun takes the next run id and number and stores the draft. The batch |
| 580 | + | // requires the workflow's disabled key absent, so a disable that races the | |||
| 581 | + | // caller's check fails the batch and is reported as domain.ErrWorkflowDisabled. | |||
| 580 | // | 582 | // | ||
| 581 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule rejecting runs of disabled workflows, atomically with creation; pending move to domain/usecase | 583 | + | //hexcheck:ignore suspicious-business-logic-in-adapter atomic precondition only: maps a conflict on the disabled-workflow key to its domain error; the usecase decides |
| 582 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | 584 | func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | ||
| 583 | offKey := workflowOffKey(repo, workflowPath) | 585 | offKey := workflowOffKey(repo, workflowPath) | ||
| 584 | for range createRunTries { | 586 | for range createRunTries { | ||
| 585 | - | // A workflow the repository disabled gets no run, whatever started it. | |||
| 586 | - | // The key is also required absent by the batch below, so a disable that | |||
| 587 | - | // lands after this read fails the batch, and the next try sees it. | |||
| 588 | - | if _, off := c.db.Get(ctx, offKey); off { | |||
| 589 | - | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 590 | - | } | |||
| 591 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | 587 | lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey)) | ||
| 592 | if err != nil { | 588 | if err != nil { | ||
| 593 | return ports.CIRunDraft{}, err | 589 | return ports.CIRunDraft{}, err | ||
| @@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s | |||||
| 620 | } | 616 | } | ||
| 621 | v, err := c.db.Apply(ctx, &b) | 617 | v, err := c.db.Apply(ctx, &b) | ||
| 622 | if errors.Is(err, kv.ErrConflict) { | 618 | if errors.Is(err, kv.ErrConflict) { | ||
| 619 | + | // Translate a failed disabled-workflow precondition; other conflicts retry. | |||
| 620 | + | if _, off := c.db.Get(ctx, offKey); off { | |||
| 621 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 622 | + | } | |||
| 623 | continue | 623 | continue | ||
| 624 | } | 624 | } | ||
| 625 | if err != nil { | 625 | if err != nil { | ||
M internal/adapters/rpc/api.go +2 -4
| @@ -87,9 +87,7 @@ type Config struct { | |||
| 87 | 87 | Secrets ports.SecretService | |
| 88 | 88 | Variables ports.VariableService | |
| 89 | 89 | Environments ports.EnvironmentService | |
| 90 | - | // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server | |
| 91 | - | // issues none (GetVersion then does not list "ci.oidc"). Same rule as | |
| 92 | - | // Sessions about typed nil pointers. | |
| 90 | + | // OIDCKeys manages ID-token signing keys, including when OIDC is disabled. | |
| 93 | 91 | OIDCKeys ports.OIDCKeyService | |
| 94 | 92 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | |
| 95 | 93 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | |
| @@ -138,7 +136,7 @@ func New(cfg Config) *API { | |||
| 138 | 136 | if artifacts { | |
| 139 | 137 | features = append(features, FeatureCIArtifacts) | |
| 140 | 138 | } | |
| 141 | - | if cfg.OIDCKeys != nil { | |
| 139 | + | if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() { | |
| 142 | 140 | features = append(features, FeatureCIOIDC) | |
| 143 | 141 | } | |
| 144 | 142 | if cfg.TrustedKeys != nil { | |
| @@ -87,9 +87,7 @@ type Config struct { | |||||
| 87 | Secrets ports.SecretService | 87 | Secrets ports.SecretService | ||
| 88 | Variables ports.VariableService | 88 | Variables ports.VariableService | ||
| 89 | Environments ports.EnvironmentService | 89 | Environments ports.EnvironmentService | ||
| 90 | - | // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server | 90 | + | // OIDCKeys manages ID-token signing keys, including when OIDC is disabled. |
| 91 | - | // issues none (GetVersion then does not list "ci.oidc"). Same rule as | |||
| 92 | - | // Sessions about typed nil pointers. | |||
| 93 | OIDCKeys ports.OIDCKeyService | 91 | OIDCKeys ports.OIDCKeyService | ||
| 94 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | 92 | // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService, | ||
| 95 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | 93 | // and GetVersion lists "ci.artifacts", when both are set and Runner is. | ||
| @@ -138,7 +136,7 @@ func New(cfg Config) *API { | |||||
| 138 | if artifacts { | 136 | if artifacts { | ||
| 139 | features = append(features, FeatureCIArtifacts) | 137 | features = append(features, FeatureCIArtifacts) | ||
| 140 | } | 138 | } | ||
| 141 | - | if cfg.OIDCKeys != nil { | 139 | + | if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() { |
| 142 | features = append(features, FeatureCIOIDC) | 140 | features = append(features, FeatureCIOIDC) | ||
| 143 | } | 141 | } | ||
| 144 | if cfg.TrustedKeys != nil { | 142 | if cfg.TrustedKeys != nil { | ||
M internal/adapters/rpc/server.go +1 -11
| @@ -4,13 +4,11 @@ package rpc | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "context" | |
| 7 | - | "errors" | |
| 8 | 7 | "slices" | |
| 9 | 8 | ||
| 10 | 9 | "connectrpc.com/connect" | |
| 11 | 10 | ||
| 12 | 11 | pb "bnema.dev/ermite-api/gen/ermite" | |
| 13 | - | "bnema.dev/ermite/internal/domain" | |
| 14 | 12 | "bnema.dev/ermite/internal/ports" | |
| 15 | 13 | ) | |
| 16 | 14 | ||
| @@ -19,7 +17,7 @@ type serverService struct { | |||
| 19 | 17 | version string | |
| 20 | 18 | features []string | |
| 21 | 19 | key ports.SigningKeyService | |
| 22 | - | // oidc rotates the key of the ID tokens; nil when the server issues none. | |
| 20 | + | // oidc manages key rotation, including the disabled-provider response. | |
| 23 | 21 | oidc ports.OIDCKeyService | |
| 24 | 22 | } | |
| 25 | 23 | ||
| @@ -42,16 +40,8 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi | |||
| 42 | 40 | ||
| 43 | 41 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | |
| 44 | 42 | // owner may; failed_precondition when the server issues no ID tokens. | |
| 45 | - | // | |
| 46 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy restricting disabled-OIDC rotation errors to owners; pending move to domain/usecase | |
| 47 | 43 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | |
| 48 | 44 | p := caller(ctx) | |
| 49 | - | if s.oidc == nil { | |
| 50 | - | if !p.HasOwnerRights() { | |
| 51 | - | return nil, connectError(ctx, domain.ErrForbidden) | |
| 52 | - | } | |
| 53 | - | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")) | |
| 54 | - | } | |
| 55 | 45 | r, err := s.oidc.RotateKey(ctx, p) | |
| 56 | 46 | if err != nil { | |
| 57 | 47 | return nil, connectError(ctx, err) | |
| @@ -4,13 +4,11 @@ package rpc | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "context" | 6 | "context" | ||
| 7 | - | "errors" | |||
| 8 | "slices" | 7 | "slices" | ||
| 9 | 8 | ||||
| 10 | "connectrpc.com/connect" | 9 | "connectrpc.com/connect" | ||
| 11 | 10 | ||||
| 12 | pb "bnema.dev/ermite-api/gen/ermite" | 11 | pb "bnema.dev/ermite-api/gen/ermite" | ||
| 13 | - | "bnema.dev/ermite/internal/domain" | |||
| 14 | "bnema.dev/ermite/internal/ports" | 12 | "bnema.dev/ermite/internal/ports" | ||
| 15 | ) | 13 | ) | ||
| 16 | 14 | ||||
| @@ -19,7 +17,7 @@ type serverService struct { | |||||
| 19 | version string | 17 | version string | ||
| 20 | features []string | 18 | features []string | ||
| 21 | key ports.SigningKeyService | 19 | key ports.SigningKeyService | ||
| 22 | - | // oidc rotates the key of the ID tokens; nil when the server issues none. | 20 | + | // oidc manages key rotation, including the disabled-provider response. |
| 23 | oidc ports.OIDCKeyService | 21 | oidc ports.OIDCKeyService | ||
| 24 | } | 22 | } | ||
| 25 | 23 | ||||
| @@ -42,16 +40,8 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi | |||||
| 42 | 40 | ||||
| 43 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | 41 | // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the | ||
| 44 | // owner may; failed_precondition when the server issues no ID tokens. | 42 | // owner may; failed_precondition when the server issues no ID tokens. | ||
| 45 | - | // | |||
| 46 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy restricting disabled-OIDC rotation errors to owners; pending move to domain/usecase | |||
| 47 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | 43 | func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) { | ||
| 48 | p := caller(ctx) | 44 | p := caller(ctx) | ||
| 49 | - | if s.oidc == nil { | |||
| 50 | - | if !p.HasOwnerRights() { | |||
| 51 | - | return nil, connectError(ctx, domain.ErrForbidden) | |||
| 52 | - | } | |||
| 53 | - | return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY")) | |||
| 54 | - | } | |||
| 55 | r, err := s.oidc.RotateKey(ctx, p) | 45 | r, err := s.oidc.RotateKey(ctx, p) | ||
| 56 | if err != nil { | 46 | if err != nil { | ||
| 57 | return nil, connectError(ctx, err) | 47 | return nil, connectError(ctx, err) | ||
M internal/adapters/web/events.go +2 -3
| @@ -342,8 +342,6 @@ func (s *Server) canSee(ctx context.Context, rawName string) error { | |||
| 342 | 342 | ||
| 343 | 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | |
| 344 | 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | |
| 345 | - | // | |
| 346 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy hiding issue streams when issues are disabled; pending move to domain/usecase | |
| 347 | 345 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |
| 348 | 346 | if s.cfg.Issues == nil { | |
| 349 | 347 | return domain.ErrNotFound | |
| @@ -356,7 +354,8 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |||
| 356 | 354 | if err != nil { | |
| 357 | 355 | return err | |
| 358 | 356 | } | |
| 359 | - | if v.Settings.Issues.OrDefault() == domain.IssuesOff { | |
| 357 | + | enabled := v.EffectiveSettings().IssuesEnabled() | |
| 358 | + | if !enabled { | |
| 360 | 359 | return domain.ErrNotFound | |
| 361 | 360 | } | |
| 362 | 361 | return nil | |
| @@ -342,8 +342,6 @@ func (s *Server) canSee(ctx context.Context, rawName string) error { | |||||
| 342 | 342 | ||||
| 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | 343 | // issuesOn is domain.ErrNotFound unless the repository has issues on and | ||
| 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | 344 | // s.cfg.Issues is set: the same answer as for a repository the visitor cannot see. | ||
| 345 | - | // | |||
| 346 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy hiding issue streams when issues are disabled; pending move to domain/usecase | |||
| 347 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | 345 | func (s *Server) issuesOn(ctx context.Context, rawName string) error { | ||
| 348 | if s.cfg.Issues == nil { | 346 | if s.cfg.Issues == nil { | ||
| 349 | return domain.ErrNotFound | 347 | return domain.ErrNotFound | ||
| @@ -356,7 +354,8 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error { | |||||
| 356 | if err != nil { | 354 | if err != nil { | ||
| 357 | return err | 355 | return err | ||
| 358 | } | 356 | } | ||
| 359 | - | if v.Settings.Issues.OrDefault() == domain.IssuesOff { | 357 | + | enabled := v.EffectiveSettings().IssuesEnabled() |
| 358 | + | if !enabled { | |||
| 360 | return domain.ErrNotFound | 359 | return domain.ErrNotFound | ||
| 361 | } | 360 | } | ||
| 362 | return nil | 361 | return nil | ||
M internal/adapters/web/issues_form.go +2 -3
| @@ -245,8 +245,6 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R | |||
| 245 | 245 | } | |
| 246 | 246 | ||
| 247 | 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | |
| 248 | - | // | |
| 249 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy exposing the submission receipt only for public issues; pending move to domain/usecase | |
| 250 | 248 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |
| 251 | 249 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | |
| 252 | 250 | defer cancel() | |
| @@ -255,7 +253,8 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |||
| 255 | 253 | s.fail(ctx, w, r, err) | |
| 256 | 254 | return | |
| 257 | 255 | } | |
| 258 | - | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | |
| 256 | + | showReceipt := d.repo.Settings.ShowsIssueSubmissionReceipt() | |
| 257 | + | if !showReceipt { | |
| 259 | 258 | s.fail(ctx, w, r, errNotFound) | |
| 260 | 259 | return | |
| 261 | 260 | } | |
| @@ -245,8 +245,6 @@ func (s *Server) formAgain(ctx context.Context, w http.ResponseWriter, r *http.R | |||||
| 245 | } | 245 | } | ||
| 246 | 246 | ||||
| 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | 247 | // issueSubmittedPage is GET /<repo>/issues/submitted. | ||
| 248 | - | // | |||
| 249 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business policy exposing the submission receipt only for public issues; pending move to domain/usecase | |||
| 250 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | 248 | func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | ||
| 251 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | 249 | ctx, cancel := context.WithTimeout(r.Context(), requestTimeout) | ||
| 252 | defer cancel() | 250 | defer cancel() | ||
| @@ -255,7 +253,8 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) { | |||||
| 255 | s.fail(ctx, w, r, err) | 253 | s.fail(ctx, w, r, err) | ||
| 256 | return | 254 | return | ||
| 257 | } | 255 | } | ||
| 258 | - | if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic { | 256 | + | showReceipt := d.repo.Settings.ShowsIssueSubmissionReceipt() |
| 257 | + | if !showReceipt { | |||
| 259 | s.fail(ctx, w, r, errNotFound) | 258 | s.fail(ctx, w, r, errNotFound) | ||
| 260 | return | 259 | return | ||
| 261 | } | 260 | } | ||
M internal/adapters/web/repo.go +2 -6
| @@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo | |||
| 44 | 44 | ||
| 45 | 45 | // repoRecord reads the repository record only, without Git: its name, | |
| 46 | 46 | // visibility and settings (the defaults when it has none). | |
| 47 | - | // | |
| 48 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule replacing zero repository settings with defaults; pending move to domain/usecase | |
| 49 | 47 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | |
| 50 | 48 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | |
| 51 | 49 | if err != nil { | |
| 52 | 50 | return domain.Repo{}, err | |
| 53 | 51 | } | |
| 54 | - | if repo.Settings.IsZero() { | |
| 55 | - | repo.Settings = domain.DefaultRepoSettings() | |
| 56 | - | } | |
| 52 | + | repo.Settings = repo.EffectiveSettings() | |
| 57 | 53 | return repo, nil | |
| 58 | 54 | } | |
| 59 | 55 | ||
| @@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||
| 157 | 153 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | |
| 158 | 154 | l.Sections = append(l.Sections, mrs) | |
| 159 | 155 | } | |
| 160 | - | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | |
| 156 | + | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { | |
| 161 | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | |
| 162 | 158 | } | |
| 163 | 159 | l.Sections = s.withActions(l.Sections, d.name(), section) | |
| @@ -44,16 +44,12 @@ func (s *Server) repoView(ctx context.Context, name domain.RepoName) (ports.Repo | |||||
| 44 | 44 | ||||
| 45 | // repoRecord reads the repository record only, without Git: its name, | 45 | // repoRecord reads the repository record only, without Git: its name, | ||
| 46 | // visibility and settings (the defaults when it has none). | 46 | // visibility and settings (the defaults when it has none). | ||
| 47 | - | // | |||
| 48 | - | //hexcheck:ignore suspicious-business-logic-in-adapter business rule replacing zero repository settings with defaults; pending move to domain/usecase | |||
| 49 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | 47 | func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) { | ||
| 50 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | 48 | repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name) | ||
| 51 | if err != nil { | 49 | if err != nil { | ||
| 52 | return domain.Repo{}, err | 50 | return domain.Repo{}, err | ||
| 53 | } | 51 | } | ||
| 54 | - | if repo.Settings.IsZero() { | 52 | + | repo.Settings = repo.EffectiveSettings() |
| 55 | - | repo.Settings = domain.DefaultRepoSettings() | |||
| 56 | - | } | |||
| 57 | return repo, nil | 53 | return repo, nil | ||
| 58 | } | 54 | } | ||
| 59 | 55 | ||||
| @@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La | |||||
| 157 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | 153 | mrs.Count = s.openMRCount(ctx, d.repo.Name) | ||
| 158 | l.Sections = append(l.Sections, mrs) | 154 | l.Sections = append(l.Sections, mrs) | ||
| 159 | } | 155 | } | ||
| 160 | - | if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff { | 156 | + | if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() { |
| 161 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | 157 | l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues")) | ||
| 162 | } | 158 | } | ||
| 163 | l.Sections = s.withActions(l.Sections, d.name(), section) | 159 | l.Sections = s.withActions(l.Sections, d.name(), section) | ||
M internal/app/forge.go +2 -7
| @@ -151,8 +151,7 @@ type forge struct { | |||
| 151 | 151 | CI *usecase.CI | |
| 152 | 152 | // Settings are the secrets, variables and environments of CI. | |
| 153 | 153 | Settings *usecase.CISettings | |
| 154 | - | // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets | |
| 155 | - | // key. | |
| 154 | + | // OIDC is the provider of ID tokens, disabled without a secrets key. | |
| 156 | 155 | OIDC *usecase.OIDC | |
| 157 | 156 | API *rpc.API | |
| 158 | 157 | Bus ports.EventBus | |
| @@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 394 | 393 | if f.Passkeys != nil { | |
| 395 | 394 | apiPasskeys = f.Passkeys | |
| 396 | 395 | } | |
| 397 | - | var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys | |
| 398 | - | if f.OIDC != nil { | |
| 399 | - | apiOIDC = f.OIDC | |
| 400 | - | } | |
| 401 | 396 | var jobArtifacts ports.JobArtifactService | |
| 402 | 397 | var artifacts ports.ArtifactService | |
| 403 | 398 | if cfg.CIArtifacts { | |
| @@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 421 | 416 | Secrets: f.Settings, | |
| 422 | 417 | Variables: f.Settings, | |
| 423 | 418 | Environments: f.Settings, | |
| 424 | - | OIDCKeys: apiOIDC, | |
| 419 | + | OIDCKeys: f.OIDC, | |
| 425 | 420 | JobArtifacts: jobArtifacts, | |
| 426 | 421 | Artifacts: artifacts, | |
| 427 | 422 | Version: serverVersion(), | |
| @@ -151,8 +151,7 @@ type forge struct { | |||||
| 151 | CI *usecase.CI | 151 | CI *usecase.CI | ||
| 152 | // Settings are the secrets, variables and environments of CI. | 152 | // Settings are the secrets, variables and environments of CI. | ||
| 153 | Settings *usecase.CISettings | 153 | Settings *usecase.CISettings | ||
| 154 | - | // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets | 154 | + | // OIDC is the provider of ID tokens, disabled without a secrets key. |
| 155 | - | // key. | |||
| 156 | OIDC *usecase.OIDC | 155 | OIDC *usecase.OIDC | ||
| 157 | API *rpc.API | 156 | API *rpc.API | ||
| 158 | Bus ports.EventBus | 157 | Bus ports.EventBus | ||
| @@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 394 | if f.Passkeys != nil { | 393 | if f.Passkeys != nil { | ||
| 395 | apiPasskeys = f.Passkeys | 394 | apiPasskeys = f.Passkeys | ||
| 396 | } | 395 | } | ||
| 397 | - | var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys | |||
| 398 | - | if f.OIDC != nil { | |||
| 399 | - | apiOIDC = f.OIDC | |||
| 400 | - | } | |||
| 401 | var jobArtifacts ports.JobArtifactService | 396 | var jobArtifacts ports.JobArtifactService | ||
| 402 | var artifacts ports.ArtifactService | 397 | var artifacts ports.ArtifactService | ||
| 403 | if cfg.CIArtifacts { | 398 | if cfg.CIArtifacts { | ||
| @@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 421 | Secrets: f.Settings, | 416 | Secrets: f.Settings, | ||
| 422 | Variables: f.Settings, | 417 | Variables: f.Settings, | ||
| 423 | Environments: f.Settings, | 418 | Environments: f.Settings, | ||
| 424 | - | OIDCKeys: apiOIDC, | 419 | + | OIDCKeys: f.OIDC, |
| 425 | JobArtifacts: jobArtifacts, | 420 | JobArtifacts: jobArtifacts, | ||
| 426 | Artifacts: artifacts, | 421 | Artifacts: artifacts, | ||
| 427 | Version: serverVersion(), | 422 | Version: serverVersion(), | ||
M internal/domain/mergerequest.go +6 -0
| @@ -569,3 +569,9 @@ func BoundBehind(n int) (count int, capped bool) { | |||
| 569 | 569 | } | |
| 570 | 570 | return n, false | |
| 571 | 571 | } | |
| 572 | + | ||
| 573 | + | // IssuesEnabled reports whether the repository exposes issue streams and pages. | |
| 574 | + | func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff } | |
| 575 | + | ||
| 576 | + | // ShowsIssueSubmissionReceipt reports whether submissions await public moderation. | |
| 577 | + | func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic } | |
| @@ -569,3 +569,9 @@ func BoundBehind(n int) (count int, capped bool) { | |||||
| 569 | } | 569 | } | ||
| 570 | return n, false | 570 | return n, false | ||
| 571 | } | 571 | } | ||
| 572 | + | ||||
| 573 | + | // IssuesEnabled reports whether the repository exposes issue streams and pages. | |||
| 574 | + | func (s RepoSettings) IssuesEnabled() bool { return s.Issues.OrDefault() != IssuesOff } | |||
| 575 | + | ||||
| 576 | + | // ShowsIssueSubmissionReceipt reports whether submissions await public moderation. | |||
| 577 | + | func (s RepoSettings) ShowsIssueSubmissionReceipt() bool { return s.Issues.OrDefault() == IssuesPublic } | |||
M internal/domain/repo.go +8 -0
| @@ -92,3 +92,11 @@ func validTopic(t string) bool { | |||
| 92 | 92 | } | |
| 93 | 93 | return true | |
| 94 | 94 | } | |
| 95 | + | ||
| 96 | + | // EffectiveSettings returns the repository settings, using defaults for an unset record. | |
| 97 | + | func (r Repo) EffectiveSettings() RepoSettings { | |
| 98 | + | if r.Settings.IsZero() { | |
| 99 | + | return DefaultRepoSettings() | |
| 100 | + | } | |
| 101 | + | return r.Settings | |
| 102 | + | } | |
| @@ -92,3 +92,11 @@ func validTopic(t string) bool { | |||||
| 92 | } | 92 | } | ||
| 93 | return true | 93 | return true | ||
| 94 | } | 94 | } | ||
| 95 | + | ||||
| 96 | + | // EffectiveSettings returns the repository settings, using defaults for an unset record. | |||
| 97 | + | func (r Repo) EffectiveSettings() RepoSettings { | |||
| 98 | + | if r.Settings.IsZero() { | |||
| 99 | + | return DefaultRepoSettings() | |||
| 100 | + | } | |||
| 101 | + | return r.Settings | |||
| 102 | + | } | |||
M internal/domain/repo_test.go +33 -0
| @@ -60,3 +60,36 @@ func TestNormalizeTopics(t *testing.T) { | |||
| 60 | 60 | }) | |
| 61 | 61 | } | |
| 62 | 62 | } | |
| 63 | + | ||
| 64 | + | func TestRepoEffectiveSettings(t *testing.T) { | |
| 65 | + | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic} | |
| 66 | + | for _, tc := range []struct { | |
| 67 | + | name string | |
| 68 | + | settings, want domain.RepoSettings | |
| 69 | + | }{ | |
| 70 | + | {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()}, | |
| 71 | + | {"custom", custom, custom}, | |
| 72 | + | } { | |
| 73 | + | t.Run(tc.name, func(t *testing.T) { | |
| 74 | + | repo := domain.Repo{Settings: tc.settings} | |
| 75 | + | assert.Equal(t, tc.want, repo.EffectiveSettings()) | |
| 76 | + | assert.Equal(t, tc.settings, repo.Settings) | |
| 77 | + | }) | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func TestRepoIssuePresentationRules(t *testing.T) { | |
| 82 | + | for _, tc := range []struct { | |
| 83 | + | mode domain.IssuesMode | |
| 84 | + | enabled, receipt bool | |
| 85 | + | }{ | |
| 86 | + | {"", false, false}, {domain.IssuesOff, false, false}, | |
| 87 | + | {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true}, | |
| 88 | + | } { | |
| 89 | + | t.Run(string(tc.mode), func(t *testing.T) { | |
| 90 | + | s := domain.RepoSettings{Issues: tc.mode} | |
| 91 | + | assert.Equal(t, tc.enabled, s.IssuesEnabled()) | |
| 92 | + | assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt()) | |
| 93 | + | }) | |
| 94 | + | } | |
| 95 | + | } | |
| @@ -60,3 +60,36 @@ func TestNormalizeTopics(t *testing.T) { | |||||
| 60 | }) | 60 | }) | ||
| 61 | } | 61 | } | ||
| 62 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | func TestRepoEffectiveSettings(t *testing.T) { | |||
| 65 | + | custom := domain.RepoSettings{AllowedMergeModes: []domain.MergeMode{domain.MergeMode("squash")}, Issues: domain.IssuesPublic} | |||
| 66 | + | for _, tc := range []struct { | |||
| 67 | + | name string | |||
| 68 | + | settings, want domain.RepoSettings | |||
| 69 | + | }{ | |||
| 70 | + | {"unset", domain.RepoSettings{}, domain.DefaultRepoSettings()}, | |||
| 71 | + | {"custom", custom, custom}, | |||
| 72 | + | } { | |||
| 73 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 74 | + | repo := domain.Repo{Settings: tc.settings} | |||
| 75 | + | assert.Equal(t, tc.want, repo.EffectiveSettings()) | |||
| 76 | + | assert.Equal(t, tc.settings, repo.Settings) | |||
| 77 | + | }) | |||
| 78 | + | } | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func TestRepoIssuePresentationRules(t *testing.T) { | |||
| 82 | + | for _, tc := range []struct { | |||
| 83 | + | mode domain.IssuesMode | |||
| 84 | + | enabled, receipt bool | |||
| 85 | + | }{ | |||
| 86 | + | {"", false, false}, {domain.IssuesOff, false, false}, | |||
| 87 | + | {domain.IssuesOwner, true, false}, {domain.IssuesPublic, true, true}, | |||
| 88 | + | } { | |||
| 89 | + | t.Run(string(tc.mode), func(t *testing.T) { | |||
| 90 | + | s := domain.RepoSettings{Issues: tc.mode} | |||
| 91 | + | assert.Equal(t, tc.enabled, s.IssuesEnabled()) | |||
| 92 | + | assert.Equal(t, tc.receipt, s.ShowsIssueSubmissionReceipt()) | |||
| 93 | + | }) | |||
| 94 | + | } | |||
| 95 | + | } | |||
M internal/ports/mocks/OIDCKeyService.go +44 -0
| @@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter { | |||
| 48 | 48 | return &OIDCKeyService_Expecter{mock: &_m.Mock} | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | // Enabled provides a mock function for the type OIDCKeyService | |
| 52 | + | func (_mock *OIDCKeyService) Enabled() bool { | |
| 53 | + | ret := _mock.Called() | |
| 54 | + | ||
| 55 | + | if len(ret) == 0 { | |
| 56 | + | panic("no return value specified for Enabled") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | var r0 bool | |
| 60 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |
| 61 | + | r0 = returnFunc() | |
| 62 | + | } else { | |
| 63 | + | r0 = ret.Get(0).(bool) | |
| 64 | + | } | |
| 65 | + | return r0 | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled' | |
| 69 | + | type OIDCKeyService_Enabled_Call struct { | |
| 70 | + | *mock.Call | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Enabled is a helper method to define mock.On call | |
| 74 | + | func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call { | |
| 75 | + | return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call { | |
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | + | run() | |
| 81 | + | }) | |
| 82 | + | return _c | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call { | |
| 86 | + | _c.Call.Return(b) | |
| 87 | + | return _c | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call { | |
| 91 | + | _c.Call.Return(run) | |
| 92 | + | return _c | |
| 93 | + | } | |
| 94 | + | ||
| 51 | 95 | // RotateKey provides a mock function for the type OIDCKeyService | |
| 52 | 96 | func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | |
| 53 | 97 | ret := _mock.Called(ctx, p) | |
| @@ -48,6 +48,50 @@ func (_m *OIDCKeyService) EXPECT() *OIDCKeyService_Expecter { | |||||
| 48 | return &OIDCKeyService_Expecter{mock: &_m.Mock} | 48 | return &OIDCKeyService_Expecter{mock: &_m.Mock} | ||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | + | // Enabled provides a mock function for the type OIDCKeyService | |||
| 52 | + | func (_mock *OIDCKeyService) Enabled() bool { | |||
| 53 | + | ret := _mock.Called() | |||
| 54 | + | ||||
| 55 | + | if len(ret) == 0 { | |||
| 56 | + | panic("no return value specified for Enabled") | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | var r0 bool | |||
| 60 | + | if returnFunc, ok := ret.Get(0).(func() bool); ok { | |||
| 61 | + | r0 = returnFunc() | |||
| 62 | + | } else { | |||
| 63 | + | r0 = ret.Get(0).(bool) | |||
| 64 | + | } | |||
| 65 | + | return r0 | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // OIDCKeyService_Enabled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Enabled' | |||
| 69 | + | type OIDCKeyService_Enabled_Call struct { | |||
| 70 | + | *mock.Call | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Enabled is a helper method to define mock.On call | |||
| 74 | + | func (_e *OIDCKeyService_Expecter) Enabled() *OIDCKeyService_Enabled_Call { | |||
| 75 | + | return &OIDCKeyService_Enabled_Call{Call: _e.mock.On("Enabled")} | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *OIDCKeyService_Enabled_Call) Run(run func()) *OIDCKeyService_Enabled_Call { | |||
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 80 | + | run() | |||
| 81 | + | }) | |||
| 82 | + | return _c | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func (_c *OIDCKeyService_Enabled_Call) Return(b bool) *OIDCKeyService_Enabled_Call { | |||
| 86 | + | _c.Call.Return(b) | |||
| 87 | + | return _c | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (_c *OIDCKeyService_Enabled_Call) RunAndReturn(run func() bool) *OIDCKeyService_Enabled_Call { | |||
| 91 | + | _c.Call.Return(run) | |||
| 92 | + | return _c | |||
| 93 | + | } | |||
| 94 | + | ||||
| 51 | // RotateKey provides a mock function for the type OIDCKeyService | 95 | // RotateKey provides a mock function for the type OIDCKeyService | ||
| 52 | func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | 96 | func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | ||
| 53 | ret := _mock.Called(ctx, p) | 97 | ret := _mock.Called(ctx, p) | ||
M internal/ports/oidc.go +2 -0
| @@ -53,6 +53,8 @@ type OIDCRotation struct { | |||
| 53 | 53 | // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC | |
| 54 | 54 | // implements it. | |
| 55 | 55 | type OIDCKeyService interface { | |
| 56 | + | // Enabled reports whether the server issues ID tokens. | |
| 57 | + | Enabled() bool | |
| 56 | 58 | // RotateKey replaces the signing key by a new one. It needs owner rights. | |
| 57 | 59 | RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error) | |
| 58 | 60 | } | |
| @@ -53,6 +53,8 @@ type OIDCRotation struct { | |||||
| 53 | // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC | 53 | // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC | ||
| 54 | // implements it. | 54 | // implements it. | ||
| 55 | type OIDCKeyService interface { | 55 | type OIDCKeyService interface { | ||
| 56 | + | // Enabled reports whether the server issues ID tokens. | |||
| 57 | + | Enabled() bool | |||
| 56 | // RotateKey replaces the signing key by a new one. It needs owner rights. | 58 | // RotateKey replaces the signing key by a new one. It needs owner rights. | ||
| 57 | RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error) | 59 | RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error) | ||
| 58 | } | 60 | } | ||
A internal/usecase/ci_creation_policy_test.go +48 -0
| @@ -0,0 +1,48 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package usecase | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "bnema.dev/ermite/internal/domain" | |
| 11 | + | "bnema.dev/ermite/internal/ports" | |
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestCreateRunWorkflowPolicy(t *testing.T) { | |
| 18 | + | repo, err := domain.ParseRepoName("project") | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | failure := errors.New("state unavailable") | |
| 21 | + | for _, tc := range []struct { | |
| 22 | + | name string | |
| 23 | + | off []string | |
| 24 | + | stateErr, want error | |
| 25 | + | }{ | |
| 26 | + | {name: "enabled"}, | |
| 27 | + | {name: "other disabled", off: []string{"other.yml"}}, | |
| 28 | + | {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled}, | |
| 29 | + | {name: "state failure", stateErr: failure, want: failure}, | |
| 30 | + | } { | |
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |
| 32 | + | state := mocks.NewCIWorkflowStore(t) | |
| 33 | + | store := mocks.NewCIStore(t) | |
| 34 | + | state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once() | |
| 35 | + | c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}} | |
| 36 | + | build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} } | |
| 37 | + | if tc.want == nil { | |
| 38 | + | store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once() | |
| 39 | + | } | |
| 40 | + | _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build) | |
| 41 | + | if tc.want == nil { | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | } else { | |
| 44 | + | require.ErrorIs(t, err, tc.want) | |
| 45 | + | } | |
| 46 | + | }) | |
| 47 | + | } | |
| 48 | + | } | |
| @@ -0,0 +1,48 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package usecase | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "bnema.dev/ermite/internal/domain" | |||
| 11 | + | "bnema.dev/ermite/internal/ports" | |||
| 12 | + | "bnema.dev/ermite/internal/ports/mocks" | |||
| 13 | + | "github.com/stretchr/testify/mock" | |||
| 14 | + | "github.com/stretchr/testify/require" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestCreateRunWorkflowPolicy(t *testing.T) { | |||
| 18 | + | repo, err := domain.ParseRepoName("project") | |||
| 19 | + | require.NoError(t, err) | |||
| 20 | + | failure := errors.New("state unavailable") | |||
| 21 | + | for _, tc := range []struct { | |||
| 22 | + | name string | |||
| 23 | + | off []string | |||
| 24 | + | stateErr, want error | |||
| 25 | + | }{ | |||
| 26 | + | {name: "enabled"}, | |||
| 27 | + | {name: "other disabled", off: []string{"other.yml"}}, | |||
| 28 | + | {name: "disabled", off: []string{"ci.yml"}, want: domain.ErrWorkflowDisabled}, | |||
| 29 | + | {name: "state failure", stateErr: failure, want: failure}, | |||
| 30 | + | } { | |||
| 31 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 32 | + | state := mocks.NewCIWorkflowStore(t) | |||
| 33 | + | store := mocks.NewCIStore(t) | |||
| 34 | + | state.EXPECT().DisabledWorkflows(context.Background(), repo).Return(tc.off, tc.stateErr).Once() | |||
| 35 | + | c := &CI{cfg: CIConfig{Store: store, WorkflowState: state}} | |||
| 36 | + | build := func(id, number int64) ports.CIRunDraft { return ports.CIRunDraft{} } | |||
| 37 | + | if tc.want == nil { | |||
| 38 | + | store.EXPECT().CreateRun(context.Background(), repo, "ci.yml", mock.Anything).Return(ports.CIRunDraft{}, nil).Once() | |||
| 39 | + | } | |||
| 40 | + | _, err := c.createRun(context.Background(), pushFacts{}, repo, "ci.yml", build) | |||
| 41 | + | if tc.want == nil { | |||
| 42 | + | require.NoError(t, err) | |||
| 43 | + | } else { | |||
| 44 | + | require.ErrorIs(t, err, tc.want) | |||
| 45 | + | } | |||
| 46 | + | }) | |||
| 47 | + | } | |||
| 48 | + | } | |||
M internal/usecase/ci_oidc.go +5 -5
| @@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil) | |||
| 31 | 31 | // server issues none. j must be assigned (its AssignedAt is set). | |
| 32 | 32 | func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | |
| 33 | 33 | c := rs.c | |
| 34 | - | if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { | |
| 34 | + | if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { | |
| 35 | 35 | return "" | |
| 36 | 36 | } | |
| 37 | 37 | now := c.now() | |
| @@ -48,11 +48,11 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | |||
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | 50 | // OIDCEnabled reports whether the server issues ID tokens. | |
| 51 | - | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil } | |
| 51 | + | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() } | |
| 52 | 52 | ||
| 53 | 53 | // OIDCDiscovery returns the OpenID Provider configuration. | |
| 54 | 54 | func (c *CI) OIDCDiscovery() ([]byte, error) { | |
| 55 | - | if c.cfg.OIDC == nil { | |
| 55 | + | if !c.cfg.OIDC.Enabled() { | |
| 56 | 56 | return nil, domain.ErrNotFound | |
| 57 | 57 | } | |
| 58 | 58 | return c.cfg.OIDC.Discovery() | |
| @@ -60,7 +60,7 @@ func (c *CI) OIDCDiscovery() ([]byte, error) { | |||
| 60 | 60 | ||
| 61 | 61 | // OIDCKeys returns the JWKS. | |
| 62 | 62 | func (c *CI) OIDCKeys() ([]byte, error) { | |
| 63 | - | if c.cfg.OIDC == nil { | |
| 63 | + | if !c.cfg.OIDC.Enabled() { | |
| 64 | 64 | return nil, domain.ErrNotFound | |
| 65 | 65 | } | |
| 66 | 66 | return c.cfg.OIDC.JWKS() | |
| @@ -73,7 +73,7 @@ func (c *CI) OIDCKeys() ([]byte, error) { | |||
| 73 | 73 | // "https://github.com/<owner>". | |
| 74 | 74 | func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) { | |
| 75 | 75 | o := c.cfg.OIDC | |
| 76 | - | if o == nil { | |
| 76 | + | if !o.Enabled() { | |
| 77 | 77 | return "", domain.ErrNotFound | |
| 78 | 78 | } | |
| 79 | 79 | if len(audience) > domain.MaxOIDCAudienceLen { | |
| @@ -31,7 +31,7 @@ var _ ports.OIDCService = (*CI)(nil) | |||||
| 31 | // server issues none. j must be assigned (its AssignedAt is set). | 31 | // server issues none. j must be assigned (its AssignedAt is set). | ||
| 32 | func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | 32 | func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | ||
| 33 | c := rs.c | 33 | c := rs.c | ||
| 34 | - | if c.cfg.OIDC == nil || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { | 34 | + | if !c.cfg.OIDC.Enabled() || !domain.IDTokenAllowed(j.Permissions, rs.run.Trusted) { |
| 35 | return "" | 35 | return "" | ||
| 36 | } | 36 | } | ||
| 37 | now := c.now() | 37 | now := c.now() | ||
| @@ -48,11 +48,11 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string { | |||||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | // OIDCEnabled reports whether the server issues ID tokens. | 50 | // OIDCEnabled reports whether the server issues ID tokens. | ||
| 51 | - | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC != nil } | 51 | + | func (c *CI) OIDCEnabled() bool { return c.cfg.OIDC.Enabled() } |
| 52 | 52 | ||||
| 53 | // OIDCDiscovery returns the OpenID Provider configuration. | 53 | // OIDCDiscovery returns the OpenID Provider configuration. | ||
| 54 | func (c *CI) OIDCDiscovery() ([]byte, error) { | 54 | func (c *CI) OIDCDiscovery() ([]byte, error) { | ||
| 55 | - | if c.cfg.OIDC == nil { | 55 | + | if !c.cfg.OIDC.Enabled() { |
| 56 | return nil, domain.ErrNotFound | 56 | return nil, domain.ErrNotFound | ||
| 57 | } | 57 | } | ||
| 58 | return c.cfg.OIDC.Discovery() | 58 | return c.cfg.OIDC.Discovery() | ||
| @@ -60,7 +60,7 @@ func (c *CI) OIDCDiscovery() ([]byte, error) { | |||||
| 60 | 60 | ||||
| 61 | // OIDCKeys returns the JWKS. | 61 | // OIDCKeys returns the JWKS. | ||
| 62 | func (c *CI) OIDCKeys() ([]byte, error) { | 62 | func (c *CI) OIDCKeys() ([]byte, error) { | ||
| 63 | - | if c.cfg.OIDC == nil { | 63 | + | if !c.cfg.OIDC.Enabled() { |
| 64 | return nil, domain.ErrNotFound | 64 | return nil, domain.ErrNotFound | ||
| 65 | } | 65 | } | ||
| 66 | return c.cfg.OIDC.JWKS() | 66 | return c.cfg.OIDC.JWKS() | ||
| @@ -73,7 +73,7 @@ func (c *CI) OIDCKeys() ([]byte, error) { | |||||
| 73 | // "https://github.com/<owner>". | 73 | // "https://github.com/<owner>". | ||
| 74 | func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) { | 74 | func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) { | ||
| 75 | o := c.cfg.OIDC | 75 | o := c.cfg.OIDC | ||
| 76 | - | if o == nil { | 76 | + | if !o.Enabled() { |
| 77 | return "", domain.ErrNotFound | 77 | return "", domain.ErrNotFound | ||
| 78 | } | 78 | } | ||
| 79 | if len(audience) > domain.MaxOIDCAudienceLen { | 79 | if len(audience) > domain.MaxOIDCAudienceLen { | ||
M internal/usecase/ci_pull.go +9 -1
| @@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64, | |||
| 681 | 681 | // createRun stores a run. The run of an open merge request is stored under the | |
| 682 | 682 | // gate, so that one that is closed meanwhile gets none (errPullClosed). | |
| 683 | 683 | func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | |
| 684 | + | off, err := c.workflowDisabled(ctx, repo, wfPath) | |
| 685 | + | if err != nil { | |
| 686 | + | return ports.CIRunDraft{}, err | |
| 687 | + | } | |
| 688 | + | if off { | |
| 689 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |
| 690 | + | } | |
| 691 | + | ||
| 684 | 692 | if f.pull == nil || f.pull.action == domain.PullClosed { | |
| 685 | 693 | return c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | |
| 686 | 694 | } | |
| 687 | 695 | var d ports.CIRunDraft | |
| 688 | - | err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { | |
| 696 | + | err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { | |
| 689 | 697 | var err error | |
| 690 | 698 | d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | |
| 691 | 699 | return err | |
| @@ -681,11 +681,19 @@ func (c *CI) whileOpen(ctx context.Context, repo domain.RepoName, number int64, | |||||
| 681 | // createRun stores a run. The run of an open merge request is stored under the | 681 | // createRun stores a run. The run of an open merge request is stored under the | ||
| 682 | // gate, so that one that is closed meanwhile gets none (errPullClosed). | 682 | // gate, so that one that is closed meanwhile gets none (errPullClosed). | ||
| 683 | func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | 683 | func (c *CI) createRun(ctx context.Context, f pushFacts, repo domain.RepoName, wfPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) { | ||
| 684 | + | off, err := c.workflowDisabled(ctx, repo, wfPath) | |||
| 685 | + | if err != nil { | |||
| 686 | + | return ports.CIRunDraft{}, err | |||
| 687 | + | } | |||
| 688 | + | if off { | |||
| 689 | + | return ports.CIRunDraft{}, domain.ErrWorkflowDisabled | |||
| 690 | + | } | |||
| 691 | + | ||||
| 684 | if f.pull == nil || f.pull.action == domain.PullClosed { | 692 | if f.pull == nil || f.pull.action == domain.PullClosed { | ||
| 685 | return c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | 693 | return c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | ||
| 686 | } | 694 | } | ||
| 687 | var d ports.CIRunDraft | 695 | var d ports.CIRunDraft | ||
| 688 | - | err := c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { | 696 | + | err = c.whileOpen(ctx, repo, f.pull.mr.Number, func() error { |
| 689 | var err error | 697 | var err error | ||
| 690 | d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | 698 | d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build) | ||
| 691 | return err | 699 | return err | ||
M internal/usecase/ci_runner.go +1 -1
| @@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po | |||
| 281 | 281 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | |
| 282 | 282 | Cache: c.cacheScope(rs.run, rs.src.GithubJSON), | |
| 283 | 283 | } | |
| 284 | - | if sec.idToken != "" && c.cfg.OIDC != nil { | |
| 284 | + | if sec.idToken != "" && c.cfg.OIDC.Enabled() { | |
| 285 | 285 | out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken | |
| 286 | 286 | } | |
| 287 | 287 | return out | |
| @@ -281,7 +281,7 @@ func (c *CI) runnerJob(rs *runState, j domain.CIJob, sec jobSecrets, settings po | |||||
| 281 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | 281 | Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted), | ||
| 282 | Cache: c.cacheScope(rs.run, rs.src.GithubJSON), | 282 | Cache: c.cacheScope(rs.run, rs.src.GithubJSON), | ||
| 283 | } | 283 | } | ||
| 284 | - | if sec.idToken != "" && c.cfg.OIDC != nil { | 284 | + | if sec.idToken != "" && c.cfg.OIDC.Enabled() { |
| 285 | out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken | 285 | out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken | ||
| 286 | } | 286 | } | ||
| 287 | return out | 287 | return out | ||
M internal/usecase/oidc.go +27 -3
| @@ -74,8 +74,8 @@ type oidcKey struct { | |||
| 74 | 74 | var _ ports.OIDCKeyService = (*OIDC)(nil) | |
| 75 | 75 | ||
| 76 | 76 | // NewOIDC loads the signing keys, generates the first one when there is none, | |
| 77 | - | // and forgets the retired keys whose publication is over. It returns nil | |
| 78 | - | // without error when cfg.Sealer is nil: the server issues no ID tokens. A | |
| 77 | + | // and forgets the retired keys whose publication is over. With cfg.Sealer nil | |
| 78 | + | // it returns a disabled service: no ID tokens are issued. A | |
| 79 | 79 | // stored key that does not open with the secrets key is an error: the server | |
| 80 | 80 | // must not start (the secrets key check already refuses another key, so this | |
| 81 | 81 | // is a damaged record). | |
| @@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) { | |||
| 83 | 83 | log := zerowrap.FromCtx(ctx) | |
| 84 | 84 | if cfg.Sealer == nil { | |
| 85 | 85 | log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens") | |
| 86 | - | return nil, nil | |
| 86 | + | return &OIDC{}, nil | |
| 87 | 87 | } | |
| 88 | 88 | if cfg.NewKey == nil { | |
| 89 | 89 | cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) } | |
| @@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) { | |||
| 180 | 180 | // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose | |
| 181 | 181 | // publication is over are deleted with it. Only the owner may rotate. | |
| 182 | 182 | func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | |
| 183 | + | if !o.Enabled() { | |
| 184 | + | if !p.HasOwnerRights() { | |
| 185 | + | return ports.OIDCRotation{}, domain.ErrForbidden | |
| 186 | + | } | |
| 187 | + | return ports.OIDCRotation{}, oidcDisabledError{} | |
| 188 | + | } | |
| 189 | + | ||
| 183 | 190 | switch { | |
| 184 | 191 | case p.IsAnonymous(): | |
| 185 | 192 | return ports.OIDCRotation{}, domain.ErrUnauthenticated | |
| @@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk { | |||
| 234 | 241 | ||
| 235 | 242 | // JWKS returns the JSON of the published keys. | |
| 236 | 243 | func (o *OIDC) JWKS() ([]byte, error) { | |
| 244 | + | if !o.Enabled() { | |
| 245 | + | return nil, domain.ErrNotFound | |
| 246 | + | } | |
| 237 | 247 | now := o.cfg.Clock.Now().UTC() | |
| 238 | 248 | o.mu.RLock() | |
| 239 | 249 | defer o.mu.RUnlock() | |
| @@ -258,6 +268,9 @@ var oidcClaims = []string{ | |||
| 258 | 268 | // Discovery returns the JSON of the OpenID Provider configuration, in the | |
| 259 | 269 | // shape of GitHub's. | |
| 260 | 270 | func (o *OIDC) Discovery() ([]byte, error) { | |
| 271 | + | if !o.Enabled() { | |
| 272 | + | return nil, domain.ErrNotFound | |
| 273 | + | } | |
| 261 | 274 | return json.Marshal(ordered{ | |
| 262 | 275 | {"issuer", o.issuer}, | |
| 263 | 276 | {"jwks_uri", o.issuer + "/.well-known/jwks"}, | |
| @@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string { | |||
| 303 | 316 | } | |
| 304 | 317 | ||
| 305 | 318 | func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } | |
| 319 | + | ||
| 320 | + | // Enabled reports whether the provider issues ID tokens. A nil provider is disabled. | |
| 321 | + | func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil } | |
| 322 | + | ||
| 323 | + | // oidcDisabledError preserves the public disabled-provider message and precondition code. | |
| 324 | + | type oidcDisabledError struct{} | |
| 325 | + | ||
| 326 | + | func (oidcDisabledError) Error() string { | |
| 327 | + | return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY" | |
| 328 | + | } | |
| 329 | + | func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed } | |
| @@ -74,8 +74,8 @@ type oidcKey struct { | |||||
| 74 | var _ ports.OIDCKeyService = (*OIDC)(nil) | 74 | var _ ports.OIDCKeyService = (*OIDC)(nil) | ||
| 75 | 75 | ||||
| 76 | // NewOIDC loads the signing keys, generates the first one when there is none, | 76 | // NewOIDC loads the signing keys, generates the first one when there is none, | ||
| 77 | - | // and forgets the retired keys whose publication is over. It returns nil | 77 | + | // and forgets the retired keys whose publication is over. With cfg.Sealer nil |
| 78 | - | // without error when cfg.Sealer is nil: the server issues no ID tokens. A | 78 | + | // it returns a disabled service: no ID tokens are issued. A |
| 79 | // stored key that does not open with the secrets key is an error: the server | 79 | // stored key that does not open with the secrets key is an error: the server | ||
| 80 | // must not start (the secrets key check already refuses another key, so this | 80 | // must not start (the secrets key check already refuses another key, so this | ||
| 81 | // is a damaged record). | 81 | // is a damaged record). | ||
| @@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) { | |||||
| 83 | log := zerowrap.FromCtx(ctx) | 83 | log := zerowrap.FromCtx(ctx) | ||
| 84 | if cfg.Sealer == nil { | 84 | if cfg.Sealer == nil { | ||
| 85 | log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens") | 85 | log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens") | ||
| 86 | - | return nil, nil | 86 | + | return &OIDC{}, nil |
| 87 | } | 87 | } | ||
| 88 | if cfg.NewKey == nil { | 88 | if cfg.NewKey == nil { | ||
| 89 | cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) } | 89 | cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) } | ||
| @@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) { | |||||
| 180 | // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose | 180 | // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose | ||
| 181 | // publication is over are deleted with it. Only the owner may rotate. | 181 | // publication is over are deleted with it. Only the owner may rotate. | ||
| 182 | func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | 182 | func (o *OIDC) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) { | ||
| 183 | + | if !o.Enabled() { | |||
| 184 | + | if !p.HasOwnerRights() { | |||
| 185 | + | return ports.OIDCRotation{}, domain.ErrForbidden | |||
| 186 | + | } | |||
| 187 | + | return ports.OIDCRotation{}, oidcDisabledError{} | |||
| 188 | + | } | |||
| 189 | + | ||||
| 183 | switch { | 190 | switch { | ||
| 184 | case p.IsAnonymous(): | 191 | case p.IsAnonymous(): | ||
| 185 | return ports.OIDCRotation{}, domain.ErrUnauthenticated | 192 | return ports.OIDCRotation{}, domain.ErrUnauthenticated | ||
| @@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk { | |||||
| 234 | 241 | ||||
| 235 | // JWKS returns the JSON of the published keys. | 242 | // JWKS returns the JSON of the published keys. | ||
| 236 | func (o *OIDC) JWKS() ([]byte, error) { | 243 | func (o *OIDC) JWKS() ([]byte, error) { | ||
| 244 | + | if !o.Enabled() { | |||
| 245 | + | return nil, domain.ErrNotFound | |||
| 246 | + | } | |||
| 237 | now := o.cfg.Clock.Now().UTC() | 247 | now := o.cfg.Clock.Now().UTC() | ||
| 238 | o.mu.RLock() | 248 | o.mu.RLock() | ||
| 239 | defer o.mu.RUnlock() | 249 | defer o.mu.RUnlock() | ||
| @@ -258,6 +268,9 @@ var oidcClaims = []string{ | |||||
| 258 | // Discovery returns the JSON of the OpenID Provider configuration, in the | 268 | // Discovery returns the JSON of the OpenID Provider configuration, in the | ||
| 259 | // shape of GitHub's. | 269 | // shape of GitHub's. | ||
| 260 | func (o *OIDC) Discovery() ([]byte, error) { | 270 | func (o *OIDC) Discovery() ([]byte, error) { | ||
| 271 | + | if !o.Enabled() { | |||
| 272 | + | return nil, domain.ErrNotFound | |||
| 273 | + | } | |||
| 261 | return json.Marshal(ordered{ | 274 | return json.Marshal(ordered{ | ||
| 262 | {"issuer", o.issuer}, | 275 | {"issuer", o.issuer}, | ||
| 263 | {"jwks_uri", o.issuer + "/.well-known/jwks"}, | 276 | {"jwks_uri", o.issuer + "/.well-known/jwks"}, | ||
| @@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string { | |||||
| 303 | } | 316 | } | ||
| 304 | 317 | ||||
| 305 | func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } | 318 | func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } | ||
| 319 | + | ||||
| 320 | + | // Enabled reports whether the provider issues ID tokens. A nil provider is disabled. | |||
| 321 | + | func (o *OIDC) Enabled() bool { return o != nil && o.cfg.Sealer != nil } | |||
| 322 | + | ||||
| 323 | + | // oidcDisabledError preserves the public disabled-provider message and precondition code. | |||
| 324 | + | type oidcDisabledError struct{} | |||
| 325 | + | ||||
| 326 | + | func (oidcDisabledError) Error() string { | |||
| 327 | + | return "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY" | |||
| 328 | + | } | |||
| 329 | + | func (oidcDisabledError) Is(target error) bool { return target == domain.ErrPreconditionFailed } | |||
M internal/usecase/oidc_test.go +29 -1
| @@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) { | |||
| 123 | 123 | f := newOIDCFixture(t) | |
| 124 | 124 | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"}) | |
| 125 | 125 | require.NoError(t, err) | |
| 126 | - | assert.Nil(t, o) | |
| 126 | + | require.NotNil(t, o) | |
| 127 | + | assert.False(t, o.Enabled()) | |
| 127 | 128 | keys, err := f.store.ListOIDCKeys(context.Background()) | |
| 128 | 129 | require.NoError(t, err) | |
| 129 | 130 | assert.Empty(t, keys, "no key is generated") | |
| @@ -338,3 +339,30 @@ func TestIDTokenClaimsPerEvent(t *testing.T) { | |||
| 338 | 339 | }) | |
| 339 | 340 | } | |
| 340 | 341 | } | |
| 342 | + | ||
| 343 | + | func TestDisabledOIDCRotationAuthorization(t *testing.T) { | |
| 344 | + | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{}) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | for _, tc := range []struct { | |
| 347 | + | name string | |
| 348 | + | p domain.Principal | |
| 349 | + | want error | |
| 350 | + | }{ | |
| 351 | + | {"anonymous", domain.Anonymous(), domain.ErrForbidden}, | |
| 352 | + | {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden}, | |
| 353 | + | {"root", domain.Root(), domain.ErrPreconditionFailed}, | |
| 354 | + | {"owner", domain.Owner(), domain.ErrPreconditionFailed}, | |
| 355 | + | } { | |
| 356 | + | t.Run(tc.name, func(t *testing.T) { | |
| 357 | + | _, err := o.RotateKey(context.Background(), tc.p) | |
| 358 | + | require.ErrorIs(t, err, tc.want) | |
| 359 | + | if tc.want == domain.ErrPreconditionFailed { | |
| 360 | + | assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY") | |
| 361 | + | } | |
| 362 | + | }) | |
| 363 | + | } | |
| 364 | + | _, err = o.Discovery() | |
| 365 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 366 | + | _, err = o.JWKS() | |
| 367 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |
| 368 | + | } | |
| @@ -123,7 +123,8 @@ func TestNoSecretsKeyNoOIDC(t *testing.T) { | |||||
| 123 | f := newOIDCFixture(t) | 123 | f := newOIDCFixture(t) | ||
| 124 | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"}) | 124 | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"}) | ||
| 125 | require.NoError(t, err) | 125 | require.NoError(t, err) | ||
| 126 | - | assert.Nil(t, o) | 126 | + | require.NotNil(t, o) |
| 127 | + | assert.False(t, o.Enabled()) | |||
| 127 | keys, err := f.store.ListOIDCKeys(context.Background()) | 128 | keys, err := f.store.ListOIDCKeys(context.Background()) | ||
| 128 | require.NoError(t, err) | 129 | require.NoError(t, err) | ||
| 129 | assert.Empty(t, keys, "no key is generated") | 130 | assert.Empty(t, keys, "no key is generated") | ||
| @@ -338,3 +339,30 @@ func TestIDTokenClaimsPerEvent(t *testing.T) { | |||||
| 338 | }) | 339 | }) | ||
| 339 | } | 340 | } | ||
| 340 | } | 341 | } | ||
| 342 | + | ||||
| 343 | + | func TestDisabledOIDCRotationAuthorization(t *testing.T) { | |||
| 344 | + | o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{}) | |||
| 345 | + | require.NoError(t, err) | |||
| 346 | + | for _, tc := range []struct { | |||
| 347 | + | name string | |||
| 348 | + | p domain.Principal | |||
| 349 | + | want error | |||
| 350 | + | }{ | |||
| 351 | + | {"anonymous", domain.Anonymous(), domain.ErrForbidden}, | |||
| 352 | + | {"limited", domain.TokenPrincipal(domain.Token{ID: "limited"}), domain.ErrForbidden}, | |||
| 353 | + | {"root", domain.Root(), domain.ErrPreconditionFailed}, | |||
| 354 | + | {"owner", domain.Owner(), domain.ErrPreconditionFailed}, | |||
| 355 | + | } { | |||
| 356 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 357 | + | _, err := o.RotateKey(context.Background(), tc.p) | |||
| 358 | + | require.ErrorIs(t, err, tc.want) | |||
| 359 | + | if tc.want == domain.ErrPreconditionFailed { | |||
| 360 | + | assert.EqualError(t, err, "the server issues no OIDC ID tokens: it has no ERMITE_SECRETS_KEY") | |||
| 361 | + | } | |||
| 362 | + | }) | |||
| 363 | + | } | |||
| 364 | + | _, err = o.Discovery() | |||
| 365 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 366 | + | _, err = o.JWKS() | |||
| 367 | + | assert.ErrorIs(t, err, domain.ErrNotFound) | |||
| 368 | + | } | |||