Skip to content

Commit f780b73

Browse files
committed
feat: implement authentication support
This feature is Enterprise only (requires BUSL). Any access to the schematic requires the user to be authenticated before access. Moreover, any schematic stores the owner in the schematic, so each schematic becomes private (owned by the user which created it). Authentication is configured using a set of usernames and keys associates with each user (API key). Signed-off-by: Andrey Smirnov <[email protected]>
1 parent b5ba663 commit f780b73

File tree

21 files changed

+423
-19
lines changed

21 files changed

+423
-19
lines changed

.golangci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
22
#
3-
# Generated on 2025-07-22T11:53:08Z by kres b869533.
3+
# Generated on 2026-01-15T15:46:46Z by kres 6f7b97a.
44

55
version: "2"
66

@@ -9,6 +9,7 @@ run:
99
modules-download-mode: readonly
1010
issues-exit-code: 1
1111
tests: true
12+
build-tags: ["enterprise"]
1213

1314
# output configuration options
1415
output:

.kres.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,3 +825,12 @@ spec:
825825
ghaction:
826826
enabled: true
827827
condition: on-pull-request
828+
---
829+
kind: golang.UnitTests
830+
spec:
831+
extraArgs: "-tags=enterprise"
832+
---
833+
kind: golang.GolangciLint
834+
spec:
835+
buildTags:
836+
- enterprise

Dockerfile

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
44
#
5-
# Generated on 2026-01-09T09:46:37Z by kres 0e8da31.
5+
# Generated on 2026-01-15T15:00:14Z by kres 6f46343.
66

77
ARG TOOLCHAIN=scratch
88
ARG PKGS_PREFIX=scratch
@@ -145,15 +145,15 @@ ENV GOEXPERIMENT=${GOEXPERIMENT}
145145
ENV GOPATH=/go
146146
ARG DEEPCOPY_VERSION
147147
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install github.com/siderolabs/deep-copy@${DEEPCOPY_VERSION} \
148-
&& mv /go/bin/deep-copy /bin/deep-copy
148+
&& mv /go/bin/deep-copy /bin/deep-copy
149149
ARG GOLANGCILINT_VERSION
150150
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@${GOLANGCILINT_VERSION} \
151-
&& mv /go/bin/golangci-lint /bin/golangci-lint
151+
&& mv /go/bin/golangci-lint /bin/golangci-lint
152152
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg go install golang.org/x/vuln/cmd/govulncheck@latest \
153-
&& mv /go/bin/govulncheck /bin/govulncheck
153+
&& mv /go/bin/govulncheck /bin/govulncheck
154154
ARG GOFUMPT_VERSION
155155
RUN go install mvdan.cc/gofumpt@${GOFUMPT_VERSION} \
156-
&& mv /go/bin/gofumpt /bin/gofumpt
156+
&& mv /go/bin/gofumpt /bin/gofumpt
157157

158158
# Copies assets
159159
FROM scratch AS tailwind-copy
@@ -223,13 +223,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache
223223
FROM base AS unit-tests-race
224224
WORKDIR /src
225225
ARG TESTPKGS
226-
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp CGO_ENABLED=1 go test -race ${TESTPKGS}
226+
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp CGO_ENABLED=1 go test -race -tags=enterprise ${TESTPKGS}
227227

228228
# runs unit-tests
229229
FROM base AS unit-tests-run
230230
WORKDIR /src
231231
ARG TESTPKGS
232-
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp go test -covermode=atomic -coverprofile=coverage.txt -coverpkg=${TESTPKGS} ${TESTPKGS}
232+
RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache/go-build --mount=type=cache,target=/go/pkg,id=image-factory/go/pkg --mount=type=cache,target=/tmp,id=image-factory/tmp go test -covermode=atomic -coverprofile=coverage.txt -coverpkg=${TESTPKGS} -tags=enterprise ${TESTPKGS}
233233

234234
# updates go.mod to use the latest talos main
235235
FROM base AS update-to-talos-main

cmd/image-factory/cmd/options.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
)
1111

1212
// Options configures the behavior of the image factory.
13-
type Options struct {
13+
type Options struct { //nolint:govet // keeping order for semantic clarity
1414
// HTTP configuration for the image factory frontend.
1515
HTTP HTTPOptions `koanf:"http"`
1616

@@ -31,6 +31,11 @@ type Options struct {
3131

3232
// Artifacts defines names and references for various images used by the factory.
3333
Artifacts ArtifactsOptions `koanf:"artifacts"`
34+
35+
// Authentication settings.
36+
//
37+
// Note: only available in the Enterprise edition.
38+
Authentication AuthenticationOptions `koanf:"authentication"`
3439
}
3540

3641
// AssetBuilderOptions contains settings for building assets.
@@ -342,6 +347,16 @@ type ComponentsOptions struct {
342347
Talosctl string `koanf:"talosctl"`
343348
}
344349

350+
// AuthenticationOptions holds authentication settings.
351+
type AuthenticationOptions struct { //nolint:govet // keeping order for semantic clarity
352+
// Enabled enables authentication.
353+
Enabled bool `koanf:"enabled"`
354+
// ConfigPath is the path to the authentication configuration file.
355+
//
356+
// It is required if authentication is enabled.
357+
ConfigPath string `koanf:"configPath"`
358+
}
359+
345360
// DefaultOptions are the default options.
346361
var DefaultOptions = Options{
347362
HTTP: HTTPOptions{

cmd/image-factory/cmd/service.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,18 @@ func RunFactory(ctx context.Context, logger *zap.Logger, opts Options) error {
108108
return fmt.Errorf("failed to initialize SecureBoot service: %w", err)
109109
}
110110

111+
var authProvider enterprise.AuthProvider
112+
113+
if opts.Authentication.Enabled {
114+
authProvider, err = enterprise.NewAuthProvider(opts.Authentication.ConfigPath)
115+
if err != nil {
116+
return fmt.Errorf("failed to initialize authentication provider: %w", err)
117+
}
118+
}
119+
111120
var frontendOptions frontendhttp.Options
112121

122+
frontendOptions.AuthProvider = authProvider
113123
frontendOptions.CacheSigningKey = cacheSigningKey
114124

115125
frontendOptions.ExternalURL, err = url.Parse(opts.HTTP.ExternalURL)

docs/api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ Well-known schematic IDs:
4242

4343
* `376567988ad370138ad8b2698212367b8edcb69b5fd68c80be1f2ec7d603b4ba` - default schematic (without any customizations)
4444

45+
The schematic in Enterprise edition may contain an `owner` field, which restricts access to the schematic to the specified owner only.
46+
This requires authentication to be enabled.
47+
4548
### `GET /schematics/:schematic`
4649

4750
Retrieve a specific schematic by its ID.

docs/configuration.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,26 @@ RefreshInterval specifies how often the image factory should refresh its connect
579579

580580
---
581581

582+
### `authentication.enabled`
583+
584+
- **Type:** `bool`
585+
- **Env:** `AUTHENTICATION_ENABLED`
586+
587+
Enabled enables authentication.
588+
589+
---
590+
591+
### `authentication.configPath`
592+
593+
- **Type:** `string`
594+
- **Env:** `AUTHENTICATION_CONFIGPATH`
595+
596+
ConfigPath is the path to the authentication configuration file.
597+
598+
It is required if authentication is enabled.
599+
600+
---
601+
582602
## Default Configuration
583603

584604
### YAML
@@ -613,6 +633,9 @@ artifacts:
613633
registry: ghcr.io
614634
repository: schematics
615635
talosVersionRecheckInterval: 15m0s
636+
authentication:
637+
configPath: ""
638+
enabled: false
616639
build:
617640
maxConcurrency: 6
618641
minTalosVersion: 1.2.0
@@ -690,6 +713,8 @@ IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory
690713
IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io
691714
IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics
692715
IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s
716+
IF_AUTHENTICATION_CONFIGPATH=
717+
IF_AUTHENTICATION_ENABLED=false
693718
IF_BUILD_MAXCONCURRENCY=6
694719
IF_BUILD_MINTALOSVERSION=1.2.0
695720
IF_CACHE_CDN_ENABLED=false

enterprise/auth/auth.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
6+
//go:build enterprise
7+
8+
// Package auth provides authentication mechanisms.
9+
package auth
10+
11+
import (
12+
"context"
13+
"errors"
14+
"net/http"
15+
"slices"
16+
17+
"github.com/julienschmidt/httprouter"
18+
"github.com/siderolabs/gen/xerrors"
19+
20+
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
21+
)
22+
23+
// NewProvider initializes a new authentication provider.
24+
func NewProvider(configPath string) (*provider, error) {
25+
config, err := LoadConfig(configPath)
26+
if err != nil {
27+
return nil, err
28+
}
29+
30+
users := make(map[string][]string)
31+
32+
for _, token := range config.APITokens {
33+
users[token.Token] = token.Passwords
34+
}
35+
36+
return &provider{
37+
users: users,
38+
}, nil
39+
}
40+
41+
type provider struct {
42+
users map[string][]string
43+
}
44+
45+
func (provider *provider) Middleware(
46+
handler func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error,
47+
) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
48+
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
49+
username, password, ok := r.BasicAuth()
50+
if !ok {
51+
return handler(ctx, w, r, p)
52+
}
53+
54+
if !provider.verifyCredentials(username, password) {
55+
return xerrors.NewTagged[schematicpkg.RequiresAuthenticationTag](errors.New("invalid credentials"))
56+
}
57+
58+
ctx = context.WithValue(ctx, authContextKey{}, username)
59+
60+
return handler(ctx, w, r, p)
61+
}
62+
}
63+
64+
func (provider *provider) verifyCredentials(username, password string) bool {
65+
return slices.Contains(provider.users[username], password)
66+
}

enterprise/auth/auth_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// Copyright (c) 2025 Sidero Labs, Inc.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
6+
//go:build enterprise
7+
8+
package auth_test
9+
10+
import (
11+
"context"
12+
"net/http"
13+
"testing"
14+
15+
"github.com/julienschmidt/httprouter"
16+
"github.com/siderolabs/gen/xerrors"
17+
"github.com/stretchr/testify/require"
18+
19+
"github.com/siderolabs/image-factory/enterprise/auth"
20+
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
21+
)
22+
23+
func TestAuthProvider(t *testing.T) {
24+
t.Parallel()
25+
26+
provider, err := auth.NewProvider("testdata/auth.yaml")
27+
require.NoError(t, err)
28+
29+
handler := func(t *testing.T, expectUser bool, expectUsername string) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
30+
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
31+
username, ok := auth.GetAuthUsername(ctx)
32+
33+
if expectUser {
34+
require.True(t, ok)
35+
require.Equal(t, expectUsername, username)
36+
} else {
37+
require.False(t, ok)
38+
}
39+
40+
return nil
41+
}
42+
}
43+
44+
for _, test := range []struct {
45+
name string
46+
47+
username string
48+
password string
49+
50+
expectAuthError bool
51+
}{
52+
{
53+
name: "no auth",
54+
},
55+
{
56+
name: "valid user1/pass1",
57+
58+
username: "user1",
59+
password: "pass1",
60+
},
61+
{
62+
name: "valid user1/pass1.1",
63+
64+
username: "user1",
65+
password: "pass1.1",
66+
},
67+
{
68+
name: "valid user2/pass2",
69+
70+
username: "user2",
71+
password: "pass2",
72+
},
73+
{
74+
name: "invalid user",
75+
76+
username: "invalid",
77+
password: "pass",
78+
79+
expectAuthError: true,
80+
},
81+
{
82+
name: "invalid password for user1",
83+
84+
username: "user1",
85+
password: "wrongpass",
86+
87+
expectAuthError: true,
88+
},
89+
} {
90+
t.Run(test.name, func(t *testing.T) {
91+
t.Parallel()
92+
93+
ctx := t.Context()
94+
95+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
96+
require.NoError(t, err)
97+
98+
if test.username != "" && test.password != "" {
99+
req.SetBasicAuth(test.username, test.password)
100+
}
101+
102+
middleware := provider.Middleware(handler(t, test.username != "", test.username))
103+
104+
err = middleware(ctx, nil, req, nil)
105+
if !test.expectAuthError {
106+
require.NoError(t, err)
107+
108+
return
109+
}
110+
111+
require.Error(t, err)
112+
require.True(t, xerrors.TagIs[schematicpkg.RequiresAuthenticationTag](err))
113+
})
114+
}
115+
}

0 commit comments

Comments
 (0)