Skip to content

Commit fee7bab

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 42a1c45 commit fee7bab

File tree

23 files changed

+526
-36
lines changed

23 files changed

+526
-36
lines changed

.golangci.yml

Lines changed: 2 additions & 2 deletions
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 2026-01-21T13:19:04Z by kres 1ffefb6.
3+
# Generated on 2026-01-26T12:29:39Z by kres b1ab497.
44

55
version: "2"
66

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

1414
# output configuration options
1515
output:

.kres.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,3 +962,12 @@ spec:
962962
runnerGroup: generic
963963
environment:
964964
REGISTRY: registry.dev.siderolabs.io
965+
---
966+
kind: golang.UnitTests
967+
spec:
968+
extraArgs: "-tags=enterprise"
969+
---
970+
kind: golang.GolangciLint
971+
spec:
972+
buildTags:
973+
- enterprise

Dockerfile

Lines changed: 3 additions & 3 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-21T13:19:04Z by kres 1ffefb6.
5+
# Generated on 2026-01-26T12:29:39Z by kres b1ab497.
66

77
ARG TOOLCHAIN=scratch
88
ARG PKGS_PREFIX=scratch
@@ -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.
@@ -348,6 +353,16 @@ type ComponentsOptions struct {
348353
Talosctl string `koanf:"talosctl"`
349354
}
350355

356+
// AuthenticationOptions holds authentication settings.
357+
type AuthenticationOptions struct { //nolint:govet // keeping order for semantic clarity
358+
// Enabled enables authentication.
359+
Enabled bool `koanf:"enabled"`
360+
// ConfigPath is the path to the authentication configuration file.
361+
//
362+
// It is required if authentication is enabled.
363+
ConfigPath string `koanf:"configPath"`
364+
}
365+
351366
// DefaultOptions are the default options.
352367
var DefaultOptions = Options{
353368
HTTP: HTTPOptions{

cmd/image-factory/cmd/service.go

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

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

123+
frontendOptions.AuthProvider = authProvider
114124
frontendOptions.CacheSigningKey = cacheSigningKey
115125

116126
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
@@ -597,6 +597,26 @@ RefreshInterval specifies how often the image factory should refresh its connect
597597

598598
---
599599

600+
### `authentication.enabled`
601+
602+
- **Type:** `bool`
603+
- **Env:** `AUTHENTICATION_ENABLED`
604+
605+
Enabled enables authentication.
606+
607+
---
608+
609+
### `authentication.configPath`
610+
611+
- **Type:** `string`
612+
- **Env:** `AUTHENTICATION_CONFIGPATH`
613+
614+
ConfigPath is the path to the authentication configuration file.
615+
616+
It is required if authentication is enabled.
617+
618+
---
619+
600620
## Default Configuration
601621

602622
### YAML
@@ -631,6 +651,9 @@ artifacts:
631651
registry: ghcr.io
632652
repository: schematics
633653
talosVersionRecheckInterval: 15m0s
654+
authentication:
655+
configPath: ""
656+
enabled: false
634657
build:
635658
maxConcurrency: 6
636659
minTalosVersion: 1.2.0
@@ -711,6 +734,8 @@ IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory
711734
IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io
712735
IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics
713736
IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s
737+
IF_AUTHENTICATION_CONFIGPATH=
738+
IF_AUTHENTICATION_ENABLED=false
714739
IF_BUILD_MAXCONCURRENCY=6
715740
IF_BUILD_MINTALOSVERSION=1.2.0
716741
IF_CACHE_CDN_ENABLED=false

enterprise/auth/auth.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 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+
"log"
15+
"net/http"
16+
"slices"
17+
18+
"github.com/julienschmidt/httprouter"
19+
"github.com/siderolabs/gen/xerrors"
20+
21+
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
22+
)
23+
24+
// NewProvider initializes a new authentication provider.
25+
func NewProvider(configPath string) (*provider, error) {
26+
config, err := LoadConfig(configPath)
27+
if err != nil {
28+
return nil, err
29+
}
30+
31+
users := make(map[string][]string)
32+
33+
for _, token := range config.APITokens {
34+
users[token.Token] = token.Passwords
35+
}
36+
37+
return &provider{
38+
users: users,
39+
}, nil
40+
}
41+
42+
type provider struct {
43+
users map[string][]string
44+
}
45+
46+
func (provider *provider) Middleware(
47+
handler func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error,
48+
) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
49+
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
50+
username, password, ok := r.BasicAuth()
51+
if !ok {
52+
return handler(ctx, w, r, p)
53+
}
54+
55+
if !provider.verifyCredentials(username, password) {
56+
return xerrors.NewTagged[schematicpkg.RequiresAuthenticationTag](errors.New("invalid credentials"))
57+
}
58+
59+
ctx = context.WithValue(ctx, authContextKey{}, username)
60+
61+
return handler(ctx, w, r, p)
62+
}
63+
}
64+
65+
func (provider *provider) verifyCredentials(username, password string) bool {
66+
log.Printf("%s %s, %v", username, password, provider.users)
67+
68+
return slices.Contains(provider.users[username], password)
69+
}

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) 2026 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)