Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
#
# Generated on 2026-01-21T13:19:04Z by kres 1ffefb6.
# Generated on 2026-01-26T12:29:39Z by kres b1ab497.

version: "2"

Expand All @@ -9,7 +9,7 @@ run:
modules-download-mode: readonly
issues-exit-code: 1
tests: true
build-tags: []
build-tags: ["enterprise"]

# output configuration options
output:
Expand Down
9 changes: 9 additions & 0 deletions .kres.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -962,3 +962,12 @@ spec:
runnerGroup: generic
environment:
REGISTRY: registry.dev.siderolabs.io
---
kind: golang.UnitTests
spec:
extraArgs: "-tags=enterprise"
---
kind: golang.GolangciLint
spec:
buildTags:
- enterprise
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

# THIS FILE WAS AUTOMATICALLY GENERATED, PLEASE DO NOT EDIT.
#
# Generated on 2026-01-21T13:19:04Z by kres 1ffefb6.
# Generated on 2026-01-26T12:29:39Z by kres b1ab497.

ARG TOOLCHAIN=scratch
ARG PKGS_PREFIX=scratch
Expand Down Expand Up @@ -223,13 +223,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build,id=image-factory/root/.cache
FROM base AS unit-tests-race
WORKDIR /src
ARG TESTPKGS
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}
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}

# runs unit-tests
FROM base AS unit-tests-run
WORKDIR /src
ARG TESTPKGS
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}
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}

# updates go.mod to use the latest talos main
FROM base AS update-to-talos-main
Expand Down
17 changes: 16 additions & 1 deletion cmd/image-factory/cmd/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
)

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

Expand All @@ -31,6 +31,11 @@ type Options struct {

// Artifacts defines names and references for various images used by the factory.
Artifacts ArtifactsOptions `koanf:"artifacts"`

// Authentication settings.
//
// Note: only available in the Enterprise edition.
Authentication AuthenticationOptions `koanf:"authentication"`
}

// AssetBuilderOptions contains settings for building assets.
Expand Down Expand Up @@ -348,6 +353,16 @@ type ComponentsOptions struct {
Talosctl string `koanf:"talosctl"`
}

// AuthenticationOptions holds authentication settings.
type AuthenticationOptions struct { //nolint:govet // keeping order for semantic clarity
// Enabled enables authentication.
Enabled bool `koanf:"enabled"`
// ConfigPath is the path to the authentication configuration file.
//
// It is required if authentication is enabled.
ConfigPath string `koanf:"configPath"`
}

// DefaultOptions are the default options.
var DefaultOptions = Options{
HTTP: HTTPOptions{
Expand Down
10 changes: 10 additions & 0 deletions cmd/image-factory/cmd/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,18 @@ func RunFactory(ctx context.Context, logger *zap.Logger, opts Options) error {
return fmt.Errorf("failed to initialize SecureBoot service: %w", err)
}

var authProvider enterprise.AuthProvider

if opts.Authentication.Enabled {
authProvider, err = enterprise.NewAuthProvider(opts.Authentication.ConfigPath)
if err != nil {
return fmt.Errorf("failed to initialize authentication provider: %w", err)
}
}

var frontendOptions frontendhttp.Options

frontendOptions.AuthProvider = authProvider
frontendOptions.CacheSigningKey = cacheSigningKey

frontendOptions.ExternalURL, err = url.Parse(opts.HTTP.ExternalURL)
Expand Down
3 changes: 3 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ Well-known schematic IDs:

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

The schematic in Enterprise edition may contain an `owner` field, which restricts access to the schematic to the specified owner only.
This requires authentication to be enabled.

### `GET /schematics/:schematic`

Retrieve a specific schematic by its ID.
Expand Down
25 changes: 25 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,26 @@ RefreshInterval specifies how often the image factory should refresh its connect

---

### `authentication.enabled`

- **Type:** `bool`
- **Env:** `AUTHENTICATION_ENABLED`

Enabled enables authentication.

---

### `authentication.configPath`

- **Type:** `string`
- **Env:** `AUTHENTICATION_CONFIGPATH`

ConfigPath is the path to the authentication configuration file.

It is required if authentication is enabled.

---

## Default Configuration

### YAML
Expand Down Expand Up @@ -631,6 +651,9 @@ artifacts:
registry: ghcr.io
repository: schematics
talosVersionRecheckInterval: 15m0s
authentication:
configPath: ""
enabled: false
build:
maxConcurrency: 6
minTalosVersion: 1.2.0
Expand Down Expand Up @@ -711,6 +734,8 @@ IF_ARTIFACTS_SCHEMATIC_NAMESPACE=siderolabs/image-factory
IF_ARTIFACTS_SCHEMATIC_REGISTRY=ghcr.io
IF_ARTIFACTS_SCHEMATIC_REPOSITORY=schematics
IF_ARTIFACTS_TALOSVERSIONRECHECKINTERVAL=15m0s
IF_AUTHENTICATION_CONFIGPATH=
IF_AUTHENTICATION_ENABLED=false
IF_BUILD_MAXCONCURRENCY=6
IF_BUILD_MINTALOSVERSION=1.2.0
IF_CACHE_CDN_ENABLED=false
Expand Down
69 changes: 69 additions & 0 deletions enterprise/auth/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright (c) 2026 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

//go:build enterprise

// Package auth provides authentication mechanisms.
package auth

import (
"context"
"errors"
"log"
"net/http"
"slices"

"github.com/julienschmidt/httprouter"
"github.com/siderolabs/gen/xerrors"

schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
)

// NewProvider initializes a new authentication provider.
func NewProvider(configPath string) (*provider, error) {
config, err := LoadConfig(configPath)
if err != nil {
return nil, err
}

users := make(map[string][]string)

for _, token := range config.APITokens {
users[token.Token] = token.Passwords
}

return &provider{
users: users,
}, nil
}

type provider struct {
users map[string][]string
}

func (provider *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 {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
username, password, ok := r.BasicAuth()
if !ok {
return handler(ctx, w, r, p)
}

if !provider.verifyCredentials(username, password) {
return xerrors.NewTagged[schematicpkg.RequiresAuthenticationTag](errors.New("invalid credentials"))
}

ctx = context.WithValue(ctx, authContextKey{}, username)

return handler(ctx, w, r, p)
}
}

func (provider *provider) verifyCredentials(username, password string) bool {
log.Printf("%s %s, %v", username, password, provider.users)

return slices.Contains(provider.users[username], password)
}
115 changes: 115 additions & 0 deletions enterprise/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// Copyright (c) 2026 Sidero Labs, Inc.
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.

//go:build enterprise

package auth_test

import (
"context"
"net/http"
"testing"

"github.com/julienschmidt/httprouter"
"github.com/siderolabs/gen/xerrors"
"github.com/stretchr/testify/require"

"github.com/siderolabs/image-factory/enterprise/auth"
schematicpkg "github.com/siderolabs/image-factory/pkg/schematic"
)

func TestAuthProvider(t *testing.T) {
t.Parallel()

provider, err := auth.NewProvider("testdata/auth.yaml")
require.NoError(t, err)

handler := func(t *testing.T, expectUser bool, expectUsername string) func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
return func(ctx context.Context, w http.ResponseWriter, r *http.Request, p httprouter.Params) error {
username, ok := auth.GetAuthUsername(ctx)

if expectUser {
require.True(t, ok)
require.Equal(t, expectUsername, username)
} else {
require.False(t, ok)
}

return nil
}
}

for _, test := range []struct {
name string

username string
password string

expectAuthError bool
}{
{
name: "no auth",
},
{
name: "valid user1/pass1",

username: "user1",
password: "pass1",
},
{
name: "valid user1/pass1.1",

username: "user1",
password: "pass1.1",
},
{
name: "valid user2/pass2",

username: "user2",
password: "pass2",
},
{
name: "invalid user",

username: "invalid",
password: "pass",

expectAuthError: true,
},
{
name: "invalid password for user1",

username: "user1",
password: "wrongpass",

expectAuthError: true,
},
} {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

ctx := t.Context()

req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil)
require.NoError(t, err)

if test.username != "" && test.password != "" {
req.SetBasicAuth(test.username, test.password)
}

middleware := provider.Middleware(handler(t, test.username != "", test.username))

err = middleware(ctx, nil, req, nil)
if !test.expectAuthError {
require.NoError(t, err)

return
}

require.Error(t, err)
require.True(t, xerrors.TagIs[schematicpkg.RequiresAuthenticationTag](err))
})
}
}
Loading
Loading