Skip to content

Commit 26120a2

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 f0150c4 commit 26120a2

File tree

13 files changed

+173
-10
lines changed

13 files changed

+173
-10
lines changed

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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
"net/http"
14+
15+
"github.com/julienschmidt/httprouter"
16+
)
17+
18+
// NewProvider initializes a new authentication provider.
19+
func NewProvider(configPath string) (*provider, error) {
20+
return &provider{}, nil
21+
}
22+
23+
type provider struct{}
24+
25+
func (p *provider) Middleware(handler func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
26+
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
27+
username, password, ok := r.BasicAuth()
28+
if !ok {
29+
return handler(ctx, w, r, p)
30+
}
31+
32+
_ = password
33+
34+
ctx = context.WithValue(ctx, authContextKey{}, username)
35+
36+
return handler(ctx, w, r, p)
37+
}
38+
}
39+
40+
type authContextKey struct{}

internal/frontend/http/configuration.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@ func (f *Frontend) handleSchematicCreate(ctx context.Context, w http.ResponseWri
2828
return err
2929
}
3030

31-
cfg, err := schematic.Unmarshal(data)
31+
schmtic, err := schematic.Unmarshal(data)
3232
if err != nil {
3333
return err
3434
}
3535

36-
id, err := f.schematicFactory.Put(ctx, cfg)
36+
id, err := f.schematicFactory.Put(ctx, schmtic)
3737
if err != nil {
3838
return err
3939
}

internal/frontend/http/http.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"github.com/siderolabs/image-factory/internal/schematic/storage"
3737
"github.com/siderolabs/image-factory/internal/secureboot"
3838
"github.com/siderolabs/image-factory/internal/version"
39+
"github.com/siderolabs/image-factory/pkg/enterprise"
3940
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
4041
)
4142

@@ -59,6 +60,7 @@ type Options struct {
5960
CacheSigningKey crypto.PrivateKey
6061
ExternalURL *url.URL
6162
ExternalPXEURL *url.URL
63+
AuthProvider enterprise.AuthProvider
6264
InstallerInternalRepository name.Repository
6365
InstallerExternalRepository name.Repository
6466
MetricsNamespace string
@@ -177,6 +179,10 @@ func (f *Frontend) wrapper(h func(ctx context.Context, w http.ResponseWriter, r
177179

178180
w.Header().Set("Server", version.ServerString())
179181

182+
if f.options.AuthProvider != nil {
183+
h = f.options.AuthProvider.Middleware(h)
184+
}
185+
180186
err := h(ctx, w, r, p)
181187

182188
duration := time.Since(start)
@@ -198,6 +204,13 @@ func (f *Frontend) wrapper(h func(ctx context.Context, w http.ResponseWriter, r
198204
status = http.StatusBadRequest
199205

200206
http.Error(w, err.Error(), http.StatusBadRequest)
207+
case xerrors.TagIs[schematicpkg.RequiresAuthenticationTag](err):
208+
level = zap.WarnLevel
209+
status = http.StatusUnauthorized
210+
211+
w.Header().Set("WWW-Authenticate", `Basic realm="Image Factory Enterprise"`)
212+
213+
http.Error(w, "authentication required to access this schematic", http.StatusUnauthorized)
201214
case errors.Is(err, context.Canceled):
202215
status = 499
203216
// client closed connection

pkg/client/client.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"encoding/json"
1212
"fmt"
1313
"io"
14+
"maps"
1415
"net/http"
1516
"net/url"
1617

@@ -36,8 +37,9 @@ type OverlayInfo struct {
3637

3738
// Client is the Image Factory HTTP API client.
3839
type Client struct {
39-
baseURL *url.URL
40-
client http.Client
40+
baseURL *url.URL
41+
extraHeaders http.Header
42+
client http.Client
4143
}
4244

4345
// New creates a new Image Factory API client.
@@ -50,8 +52,9 @@ func New(baseURL string, options ...Option) (*Client, error) {
5052
}
5153

5254
c := &Client{
53-
baseURL: bURL,
54-
client: opts.Client,
55+
baseURL: bURL,
56+
client: opts.Client,
57+
extraHeaders: opts.ExtraHeaders,
5558
}
5659

5760
return c, nil
@@ -141,6 +144,8 @@ func (c *Client) do(ctx context.Context, method, uri string, requestData []byte,
141144
req.Header.Add(k, v)
142145
}
143146

147+
maps.Copy(req.Header, c.extraHeaders)
148+
144149
resp, err := c.client.Do(req)
145150
if err != nil {
146151
return err

pkg/client/options.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,15 @@
44

55
package client
66

7-
import "net/http"
7+
import (
8+
"encoding/base64"
9+
"net/http"
10+
)
811

912
// Options defines client options.
1013
type Options struct {
14+
// ExtraHeaders represents extra headers to be added to each request.
15+
ExtraHeaders http.Header
1116
// Client is the http client.
1217
Client http.Client
1318
}
@@ -22,6 +27,19 @@ func WithClient(client http.Client) Option {
2227
}
2328
}
2429

30+
// WithBasicAuth adds basic authentication to each request.
31+
func WithBasicAuth(username, password string) Option {
32+
return func(o *Options) {
33+
if o.ExtraHeaders == nil {
34+
o.ExtraHeaders = http.Header{}
35+
}
36+
37+
auth := username + ":" + password
38+
39+
o.ExtraHeaders.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(auth)))
40+
}
41+
}
42+
2543
func withDefaults(options []Option) *Options {
2644
opts := &Options{}
2745

pkg/enterprise/enterprise.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Package enterprise provide glue to Enterprise code.
2+
package enterprise
3+
4+
import (
5+
"context"
6+
"net/http"
7+
8+
"github.com/julienschmidt/httprouter"
9+
)
10+
11+
// AuthProvider defines an authentication provider.
12+
type AuthProvider interface {
13+
Middleware(func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error
14+
}

0 commit comments

Comments
 (0)