detached c902d2f

merge: hexmove/policies into chore/hexcheck-v0.4.0

commit
c902d2f1982c6c6fe2b2868f8aedb4a92a2589c1
author
brice · · 2026-10-19 19:35 UTC
committer
brice · · 2026-10-19 20:01 UTC
merge of
6b4272d + c0f84a6 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
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) {
576576
577577 const createRunTries = 20
578578
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.
580582 //
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
582584 func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath string, build func(id, number int64) ports.CIRunDraft) (ports.CIRunDraft, error) {
583585 offKey := workflowOffKey(repo, workflowPath)
584586 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- }
591587 lastID, idItem, idOK, err := c.readSeq(ctx, []byte(runSeqKey))
592588 if err != nil {
593589 return ports.CIRunDraft{}, err
@@ -620,6 +616,10 @@ func (c *CI) CreateRun(ctx context.Context, repo domain.RepoName, workflowPath s
620616 }
621617 v, err := c.db.Apply(ctx, &b)
622618 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+ }
623623 continue
624624 }
625625 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 = 20577 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/usecase583+//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{}, err589 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 continue623 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 {
8787 Secrets ports.SecretService
8888 Variables ports.VariableService
8989 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.
9391 OIDCKeys ports.OIDCKeyService
9492 // JobArtifacts and Artifacts serve JobArtifactService and ArtifactService,
9593 // and GetVersion lists "ci.artifacts", when both are set and Runner is.
@@ -138,7 +136,7 @@ func New(cfg Config) *API {
138136 if artifacts {
139137 features = append(features, FeatureCIArtifacts)
140138 }
141- if cfg.OIDCKeys != nil {
139+ if cfg.OIDCKeys != nil && cfg.OIDCKeys.Enabled() {
142140 features = append(features, FeatureCIOIDC)
143141 }
144142 if cfg.TrustedKeys != nil {
@@ -87,9 +87,7 @@ type Config struct {
87 Secrets ports.SecretService87 Secrets ports.SecretService
88 Variables ports.VariableService88 Variables ports.VariableService
89 Environments ports.EnvironmentService89 Environments ports.EnvironmentService
90- // OIDCKeys rotates the key of the ID tokens of CI jobs; nil when the server90+ // 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.OIDCKeyService91 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
44
55 import (
66 "context"
7- "errors"
87 "slices"
98
109 "connectrpc.com/connect"
1110
1211 pb "bnema.dev/ermite-api/gen/ermite"
13- "bnema.dev/ermite/internal/domain"
1412 "bnema.dev/ermite/internal/ports"
1513 )
1614
@@ -19,7 +17,7 @@ type serverService struct {
1917 version string
2018 features []string
2119 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.
2321 oidc ports.OIDCKeyService
2422 }
2523
@@ -42,16 +40,8 @@ func (s *serverService) GetSigningKey(context.Context, *connect.Request[pb.GetSi
4240
4341 // RotateOIDCKey replaces the key that signs the ID tokens of CI jobs. Only the
4442 // 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
4743 func (s *serverService) RotateOIDCKey(ctx context.Context, _ *connect.Request[pb.RotateOIDCKeyRequest]) (*connect.Response[pb.RotateOIDCKeyResponse], error) {
4844 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- }
5545 r, err := s.oidc.RotateKey(ctx, p)
5646 if err != nil {
5747 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 string17 version string
20 features []string18 features []string
21 key ports.SigningKeyService19 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.OIDCKeyService21 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 the41 // 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 {
342342
343343 // issuesOn is domain.ErrNotFound unless the repository has issues on and
344344 // 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
347345 func (s *Server) issuesOn(ctx context.Context, rawName string) error {
348346 if s.cfg.Issues == nil {
349347 return domain.ErrNotFound
@@ -356,7 +354,8 @@ func (s *Server) issuesOn(ctx context.Context, rawName string) error {
356354 if err != nil {
357355 return err
358356 }
359- if v.Settings.Issues.OrDefault() == domain.IssuesOff {
357+ enabled := v.EffectiveSettings().IssuesEnabled()
358+ if !enabled {
360359 return domain.ErrNotFound
361360 }
362361 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 and343 // 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.ErrNotFound347 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 err355 return err
358 }356 }
359- if v.Settings.Issues.OrDefault() == domain.IssuesOff {357+ enabled := v.EffectiveSettings().IssuesEnabled()
358+ if !enabled {
360 return domain.ErrNotFound359 return domain.ErrNotFound
361 }360 }
362 return nil361 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
245245 }
246246
247247 // 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
250248 func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
251249 ctx, cancel := context.WithTimeout(r.Context(), requestTimeout)
252250 defer cancel()
@@ -255,7 +253,8 @@ func (s *Server) issueSubmittedPage(w http.ResponseWriter, r *http.Request) {
255253 s.fail(ctx, w, r, err)
256254 return
257255 }
258- if d.repo.Settings.Issues.OrDefault() != domain.IssuesPublic {
256+ showReceipt := d.repo.Settings.ShowsIssueSubmissionReceipt()
257+ if !showReceipt {
259258 s.fail(ctx, w, r, errNotFound)
260259 return
261260 }
@@ -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 return254 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 return259 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
4444
4545 // repoRecord reads the repository record only, without Git: its name,
4646 // 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
4947 func (s *Server) repoRecord(ctx context.Context, name domain.RepoName) (domain.Repo, error) {
5048 repo, err := s.cfg.Repos.Get(ctx, principal(ctx), name)
5149 if err != nil {
5250 return domain.Repo{}, err
5351 }
54- if repo.Settings.IsZero() {
55- repo.Settings = domain.DefaultRepoSettings()
56- }
52+ repo.Settings = repo.EffectiveSettings()
5753 return repo, nil
5854 }
5955
@@ -157,7 +153,7 @@ func (s *Server) repoLayout(ctx context.Context, d *repoData, section string) La
157153 mrs.Count = s.openMRCount(ctx, d.repo.Name)
158154 l.Sections = append(l.Sections, mrs)
159155 }
160- if s.cfg.Issues != nil && d.repo.Settings.Issues.OrDefault() != domain.IssuesOff {
156+ if s.cfg.Issues != nil && d.repo.EffectiveSettings().IssuesEnabled() {
161157 l.Sections = append(l.Sections, mk("i", "Issues", issuesBase(d.name()), "issues"))
162158 }
163159 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{}, err50 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, nil53 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 {
151151 CI *usecase.CI
152152 // Settings are the secrets, variables and environments of CI.
153153 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.
156155 OIDC *usecase.OIDC
157156 API *rpc.API
158157 Bus ports.EventBus
@@ -394,10 +393,6 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
394393 if f.Passkeys != nil {
395394 apiPasskeys = f.Passkeys
396395 }
397- var apiOIDC ports.OIDCKeyService // same rule as apiPasskeys
398- if f.OIDC != nil {
399- apiOIDC = f.OIDC
400- }
401396 var jobArtifacts ports.JobArtifactService
402397 var artifacts ports.ArtifactService
403398 if cfg.CIArtifacts {
@@ -421,7 +416,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e
421416 Secrets: f.Settings,
422417 Variables: f.Settings,
423418 Environments: f.Settings,
424- OIDCKeys: apiOIDC,
419+ OIDCKeys: f.OIDC,
425420 JobArtifacts: jobArtifacts,
426421 Artifacts: artifacts,
427422 Version: serverVersion(),
@@ -151,8 +151,7 @@ type forge struct {
151 CI *usecase.CI151 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.CISettings153 Settings *usecase.CISettings
154- // OIDC is the provider of the ID tokens of CI jobs; nil without a secrets154+ // OIDC is the provider of ID tokens, disabled without a secrets key.
155- // key.
156 OIDC *usecase.OIDC155 OIDC *usecase.OIDC
157 API *rpc.API156 API *rpc.API
158 Bus ports.EventBus157 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.Passkeys394 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.JobArtifactService396 var jobArtifacts ports.JobArtifactService
402 var artifacts ports.ArtifactService397 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) {
569569 }
570570 return n, false
571571 }
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, false570 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 {
9292 }
9393 return true
9494 }
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 true93 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) {
6060 })
6161 }
6262 }
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 {
4848 return &OIDCKeyService_Expecter{mock: &_m.Mock}
4949 }
5050
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+
5195 // RotateKey provides a mock function for the type OIDCKeyService
5296 func (_mock *OIDCKeyService) RotateKey(ctx context.Context, p domain.Principal) (ports.OIDCRotation, error) {
5397 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 OIDCKeyService95 // 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 {
5353 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC
5454 // implements it.
5555 type OIDCKeyService interface {
56+ // Enabled reports whether the server issues ID tokens.
57+ Enabled() bool
5658 // RotateKey replaces the signing key by a new one. It needs owner rights.
5759 RotateKey(ctx context.Context, p domain.Principal) (OIDCRotation, error)
5860 }
@@ -53,6 +53,8 @@ type OIDCRotation struct {
53 // OIDCKeyService manages the signing key of the ID tokens. *usecase.OIDC53 // 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)
3131 // server issues none. j must be assigned (its AssignedAt is set).
3232 func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
3333 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) {
3535 return ""
3636 }
3737 now := c.now()
@@ -48,11 +48,11 @@ func (rs *runState) issueIDTokenRequest(j *domain.CIJob) string {
4848 }
4949
5050 // 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() }
5252
5353 // OIDCDiscovery returns the OpenID Provider configuration.
5454 func (c *CI) OIDCDiscovery() ([]byte, error) {
55- if c.cfg.OIDC == nil {
55+ if !c.cfg.OIDC.Enabled() {
5656 return nil, domain.ErrNotFound
5757 }
5858 return c.cfg.OIDC.Discovery()
@@ -60,7 +60,7 @@ func (c *CI) OIDCDiscovery() ([]byte, error) {
6060
6161 // OIDCKeys returns the JWKS.
6262 func (c *CI) OIDCKeys() ([]byte, error) {
63- if c.cfg.OIDC == nil {
63+ if !c.cfg.OIDC.Enabled() {
6464 return nil, domain.ErrNotFound
6565 }
6666 return c.cfg.OIDC.JWKS()
@@ -73,7 +73,7 @@ func (c *CI) OIDCKeys() ([]byte, error) {
7373 // "https://github.com/<owner>".
7474 func (c *CI) IDToken(ctx context.Context, secret, audience string) (string, error) {
7575 o := c.cfg.OIDC
76- if o == nil {
76+ if !o.Enabled() {
7777 return "", domain.ErrNotFound
7878 }
7979 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.c33 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.ErrNotFound56 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.ErrNotFound64 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.OIDC75 o := c.cfg.OIDC
76- if o == nil {76+ if !o.Enabled() {
77 return "", domain.ErrNotFound77 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,
681681 // createRun stores a run. The run of an open merge request is stored under the
682682 // gate, so that one that is closed meanwhile gets none (errPullClosed).
683683 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+
684692 if f.pull == nil || f.pull.action == domain.PullClosed {
685693 return c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
686694 }
687695 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 {
689697 var err error
690698 d, err = c.cfg.Store.CreateRun(ctx, repo, wfPath, build)
691699 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 the681 // 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.CIRunDraft695 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 error697 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 err699 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
281281 Permissions: domain.TokenPermissions(j.Permissions, rs.run.Trusted),
282282 Cache: c.cacheScope(rs.run, rs.src.GithubJSON),
283283 }
284- if sec.idToken != "" && c.cfg.OIDC != nil {
284+ if sec.idToken != "" && c.cfg.OIDC.Enabled() {
285285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286286 }
287287 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.idToken285 out.IDTokenURL, out.IDTokenRequestToken = c.cfg.OIDC.TokenURL(), sec.idToken
286 }286 }
287 return out287 return out
M internal/usecase/oidc.go +27 -3
@@ -74,8 +74,8 @@ type oidcKey struct {
7474 var _ ports.OIDCKeyService = (*OIDC)(nil)
7575
7676 // 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
7979 // stored key that does not open with the secrets key is an error: the server
8080 // must not start (the secrets key check already refuses another key, so this
8181 // is a damaged record).
@@ -83,7 +83,7 @@ func NewOIDC(ctx context.Context, cfg OIDCConfig) (*OIDC, error) {
8383 log := zerowrap.FromCtx(ctx)
8484 if cfg.Sealer == nil {
8585 log.Info().Msg("ERMITE_SECRETS_KEY is not set: CI jobs get no OIDC ID tokens")
86- return nil, nil
86+ return &OIDC{}, nil
8787 }
8888 if cfg.NewKey == nil {
8989 cfg.NewKey = func() (*rsa.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, OIDCKeyBits) }
@@ -180,6 +180,13 @@ func (o *OIDC) signing() (oidcKey, bool) {
180180 // and stays in the JWKS for domain.OIDCKeyRetention. Retired keys whose
181181 // publication is over are deleted with it. Only the owner may rotate.
182182 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+
183190 switch {
184191 case p.IsAnonymous():
185192 return ports.OIDCRotation{}, domain.ErrUnauthenticated
@@ -234,6 +241,9 @@ func publicJWK(kid string, pub *rsa.PublicKey) jwk {
234241
235242 // JWKS returns the JSON of the published keys.
236243 func (o *OIDC) JWKS() ([]byte, error) {
244+ if !o.Enabled() {
245+ return nil, domain.ErrNotFound
246+ }
237247 now := o.cfg.Clock.Now().UTC()
238248 o.mu.RLock()
239249 defer o.mu.RUnlock()
@@ -258,6 +268,9 @@ var oidcClaims = []string{
258268 // Discovery returns the JSON of the OpenID Provider configuration, in the
259269 // shape of GitHub's.
260270 func (o *OIDC) Discovery() ([]byte, error) {
271+ if !o.Enabled() {
272+ return nil, domain.ErrNotFound
273+ }
261274 return json.Marshal(ordered{
262275 {"issuer", o.issuer},
263276 {"jwks_uri", o.issuer + "/.well-known/jwks"},
@@ -303,3 +316,14 @@ func thumbprint(pub *rsa.PublicKey) string {
303316 }
304317
305318 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 nil77+// 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. A78+// 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 server79 // 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 this80 // 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, nil86+ 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 whose180 // 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.ErrUnauthenticated192 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 the268 // 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) {
123123 f := newOIDCFixture(t)
124124 o, err := usecase.NewOIDC(context.Background(), usecase.OIDCConfig{Store: f.store, Tokens: f.store.CI(), Clock: f.clock, PublicURL: "https://forge.test"})
125125 require.NoError(t, err)
126- assert.Nil(t, o)
126+ require.NotNil(t, o)
127+ assert.False(t, o.Enabled())
127128 keys, err := f.store.ListOIDCKeys(context.Background())
128129 require.NoError(t, err)
129130 assert.Empty(t, keys, "no key is generated")
@@ -338,3 +339,30 @@ func TestIDTokenClaimsPerEvent(t *testing.T) {
338339 })
339340 }
340341 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close