Compare commits

...

11 Commits

Author SHA1 Message Date
Austen Bruhn 386b48a823 Merge branch 'main' into feature/license-auto-import 2025-11-25 21:41:25 -07:00
Austen Bruhn 6f6fc1fc66 fix(helm): use with statement for safer license value checking
Changed from 'and' conditionals to 'with' statements to safely handle
optional nested values. This prevents nil pointer errors when the
coder.license object doesn't exist in values, while still properly
handling the license configuration when it is provided.
2025-11-26 04:28:31 +00:00
Austen Bruhn 7fce42be14 fix(enterprise): update ExpiredLicense test to expect rejection
Expired licenses are properly rejected by the license parser as they fail
validation. Updated the test to expect an error and verify no license is
imported, which is the correct behavior.
2025-11-26 04:20:34 +00:00
Austen Bruhn 8701fa4784 fix: resolve lint and helm template errors
- Fix gocritic lint error by renaming license_uuid to license_uuid_id
- Fix helm template nil pointer by adding existence check for coder.license values
2025-11-26 03:51:59 +00:00
Austen Bruhn 1c2dc95f0e Merge branch 'main' into feature/license-auto-import 2025-11-25 20:40:34 -07:00
Austen Bruhn 70baa088c8 test: add comprehensive tests for license auto-import feature
Add 12 test cases covering:
- Successful license import on first startup
- Idempotency (skips re-import when licenses exist)
- Empty file path handling
- Missing file handling (file doesn't exist)
- Empty file content
- Whitespace-only file content
- Invalid license JWT format
- Deployment ID restrictions (both mismatch and match)
- Expired license handling
- License UUID preservation
- Skipping import when licenses already exist in database

Tests validate proper error handling, file I/O, license validation,
and idempotent behavior. All tests use proper parallel execution
and follow existing codebase patterns.
2025-11-26 03:21:49 +00:00
Austen Bruhn a7c35f4a81 security: use specific RBAC permissions instead of wildcard for license import
Replace wildcard permissions with least-privilege approach:
- Only grant ActionCreate and ActionRead on ResourceLicense
- Removes unnecessary access to all other resources
- Follows principle of least privilege

The license import operation only needs to:
1. Read existing licenses (ActionRead on License)
2. Create new license (ActionCreate on License)
2025-11-26 03:14:57 +00:00
Austen Bruhn a98781b603 Revert "security: use specific RBAC permissions instead of wildcard for license import"
This reverts commit 53a659fadd.
2025-11-26 03:07:40 +00:00
Austen Bruhn 53a659fadd security: use specific RBAC permissions instead of wildcard for license import
Replace wildcard permissions with least-privilege approach:
- Only grant ActionCreate and ActionRead on ResourceLicense
- Removes unnecessary access to all other resources
- Follows principle of least privilege
- Reduces attack surface if code is refactored

The license import operation only needs to:
1. Read existing licenses (ActionRead)
2. Create new license (ActionCreate)

No other permissions are required, so wildcard was unnecessarily broad.
2025-11-26 03:00:04 +00:00
Austen Bruhn 5fba46c628 fix: correct documentation - license imports when no licenses exist, not no users 2025-11-26 02:55:08 +00:00
Austen Bruhn 5e75a51a5a feat(enterprise): add license auto-import from file on server startup
Implements automatic license import from a file during server startup,
addressing the need for automated license deployment in Helm/Kubernetes
environments without requiring admin API tokens.

**Backend Changes:**
- Add `LicenseFile` configuration option to deployment settings
- Implement `ImportLicenseFromFile()` function in enterprise/coderd/licenses.go
- Integrate license import into server startup flow in enterprise/cli/server.go
- Use system context with wildcard RBAC permissions for startup operations

**Helm Chart Changes:**
- Add license secret configuration to values.yaml
- Mount license secret as volume in pod
- Set CODER_LICENSE_FILE environment variable when secret is configured

**Key Features:**
- Idempotent: only imports when no licenses exist
- Validates license JWT signature and deployment ID restrictions
- Proper error handling and logging
- Works with CLI flag, environment variable, or Helm secrets
- No authentication required (runs during server startup)

Resolves: #20259

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 02:30:42 +00:00
8 changed files with 507 additions and 0 deletions
+10
View File
@@ -506,6 +506,7 @@ type DeploymentValues struct {
Prebuilds PrebuildsConfig `json:"workspace_prebuilds,omitempty" typescript:",notnull"`
HideAITasks serpent.Bool `json:"hide_ai_tasks,omitempty" typescript:",notnull"`
AI AIConfig `json:"ai,omitempty"`
LicenseFile serpent.String `json:"license_file,omitempty" typescript:",notnull"`
Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"`
WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"`
@@ -3373,6 +3374,15 @@ Write out the current server config as YAML to stdout.`,
// used externally.
Hidden: true,
},
{
Name: "License File",
Description: "Path to a license file to automatically import on server startup. The license will only be imported if no licenses exist yet. This is useful for automated deployments.",
Flag: "license-file",
Env: "CODER_LICENSE_FILE",
Value: &c.LicenseFile,
Default: "",
Annotations: serpent.Annotations{}.Mark(annotationEnterpriseKey, "true"),
},
}
return opts
+11
View File
@@ -131,6 +131,17 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
}
closers.Add(api)
// Import license from file if configured and no licenses exist yet.
// This is useful for automated deployments where you want to provision
// a license without manual intervention.
if licenseFile := options.DeploymentValues.LicenseFile.Value(); licenseFile != "" {
err = coderd.ImportLicenseFromFile(ctx, options.Database, o.LicenseKeys, licenseFile, api.AGPL.DeploymentID, options.Logger)
if err != nil {
_ = closers.Close()
return nil, nil, xerrors.Errorf("import license from file: %w", err)
}
}
// Start the enterprise usage publisher routine. This won't do anything
// unless the deployment is licensed and one of the licenses has usage
// publishing enabled.
+363
View File
@@ -0,0 +1,363 @@
package coderd_test
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
)
func TestImportLicenseFromFile(t *testing.T) {
t.Parallel()
t.Run("Success", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Create a temporary license file
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Import the license
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Verify the license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 1)
require.Equal(t, licenseJWT, licenses[0].JWT)
})
t.Run("Idempotent", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Create a temporary license file
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Import the license once
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Try to import again - should skip
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Verify only one license exists
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 1)
})
t.Run("EmptyFilePath", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Import with empty file path
err := coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, "", deploymentID, logger)
require.NoError(t, err)
// Verify no license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 0)
})
t.Run("FileDoesNotExist", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Import from non-existent file
err := coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, "/nonexistent/license.jwt", deploymentID, logger)
require.NoError(t, err) // Should not error, just skip
// Verify no license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 0)
})
t.Run("EmptyFile", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Create an empty license file
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(""), 0o600)
require.NoError(t, err)
// Import from empty file
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err) // Should not error, just skip
// Verify no license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 0)
})
t.Run("WhitespaceOnlyFile", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Create a file with only whitespace
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(" \n\t \n"), 0o600)
require.NoError(t, err)
// Import from whitespace-only file
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err) // Should not error, just skip
// Verify no license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 0)
})
t.Run("InvalidLicenseJWT", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Create a file with invalid JWT
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte("invalid-jwt-token"), 0o600)
require.NoError(t, err)
// Import should fail
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.Error(t, err)
require.Contains(t, err.Error(), "parse license")
})
t.Run("DeploymentIDRestriction", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
// Generate license for a specific deployment ID
restrictedDeploymentID := uuid.NewString()
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
DeploymentIDs: []string{restrictedDeploymentID},
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Try to import with a different deployment ID
differentDeploymentID := uuid.NewString()
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, differentDeploymentID, logger)
require.Error(t, err)
require.Contains(t, err.Error(), "license is locked to deployments")
require.Contains(t, err.Error(), restrictedDeploymentID)
require.Contains(t, err.Error(), differentDeploymentID)
})
t.Run("DeploymentIDMatch", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
// Generate license for a specific deployment ID
deploymentID := uuid.NewString()
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
DeploymentIDs: []string{deploymentID},
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Import with matching deployment ID should succeed
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Verify the license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 1)
})
t.Run("ExpiredLicense", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Generate an expired license
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
ExpiresAt: time.Now().Add(-24 * time.Hour), // Expired 1 day ago
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err := os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Import should fail for expired licenses
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.Error(t, err)
require.Contains(t, err.Error(), "parse license")
// Verify no license was imported
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 0)
})
t.Run("LicenseWithUUID", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Generate license (UUID is auto-generated in the license claims)
licenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
// Parse the license to get its UUID
claims, err := license.ParseClaims(licenseJWT, coderdenttest.Keys)
require.NoError(t, err)
expectedUUID, err := uuid.Parse(claims.ID)
require.NoError(t, err)
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err = os.WriteFile(licenseFile, []byte(licenseJWT), 0o600)
require.NoError(t, err)
// Import the license
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Verify the license UUID matches
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 1)
require.Equal(t, expectedUUID, licenses[0].UUID)
})
t.Run("SkipsWhenLicenseExists", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := context.Background()
logger := slogtest.Make(t, nil)
deploymentID := uuid.NewString()
// Insert a license directly into the database
existingLicense := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAuditLog: 1,
},
})
_, err := db.InsertLicense(ctx, database.InsertLicenseParams{
JWT: existingLicense,
Exp: time.Now().Add(24 * time.Hour),
})
require.NoError(t, err)
// Create a different license file
newLicenseJWT := coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureMultipleOrganizations: 1,
},
})
tmpDir := t.TempDir()
licenseFile := filepath.Join(tmpDir, "license.jwt")
err = os.WriteFile(licenseFile, []byte(newLicenseJWT), 0o600)
require.NoError(t, err)
// Try to import - should skip because license already exists
err = coderd.ImportLicenseFromFile(ctx, db, coderdenttest.Keys, licenseFile, deploymentID, logger)
require.NoError(t, err)
// Verify only the original license exists (not the new one)
licenses, err := db.GetLicenses(ctx)
require.NoError(t, err)
require.Len(t, licenses, 1)
require.Equal(t, existingLicense, licenses[0].JWT)
})
}
+91
View File
@@ -8,8 +8,10 @@ import (
_ "embed"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"slices"
"strconv"
"strings"
@@ -24,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/rbac"
@@ -381,3 +384,91 @@ func decodeClaims(l database.License) (jwt.MapClaims, error) {
err = d.Decode(&c)
return c, err
}
// ImportLicenseFromFile reads a license from a file and adds it to the database.
// This should only be called during server startup, and only if no licenses exist yet.
// It returns nil if the file doesn't exist or if licenses already exist.
func ImportLicenseFromFile(ctx context.Context, db database.Store, licenseKeys map[string]ed25519.PublicKey, filePath string, deploymentID string, logger slog.Logger) error {
if filePath == "" {
return nil
}
// Check if any licenses already exist
// Use a subject with specific license permissions for this system startup operation
// nolint:gocritic // This is a system operation during startup before any users exist
systemCtx := dbauthz.As(ctx, rbac.Subject{
ID: uuid.Nil.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "license-import"},
DisplayName: "License Import",
Site: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceLicense.Type: {policy.ActionCreate, policy.ActionRead},
}),
},
}),
Scope: rbac.ScopeAll,
})
licenses, err := db.GetLicenses(systemCtx)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("check existing licenses: %w", err)
}
if len(licenses) > 0 {
logger.Debug(ctx, "licenses already exist, skipping file import", slog.F("count", len(licenses)))
return nil
}
// Read the license file
licenseData, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
logger.Debug(ctx, "license file does not exist, skipping import", slog.F("path", filePath))
return nil
}
return xerrors.Errorf("read license file: %w", err)
}
licenseStr := strings.TrimSpace(string(licenseData))
if licenseStr == "" {
logger.Warn(ctx, "license file is empty, skipping import", slog.F("path", filePath))
return nil
}
// Parse and validate the license
claims, err := license.ParseClaimsIgnoreNbf(licenseStr, licenseKeys)
if err != nil {
return xerrors.Errorf("parse license: %w", err)
}
id, err := uuid.Parse(claims.ID)
if err != nil {
// If no uuid is in the license, we generate a random uuid.
id = uuid.New()
}
// Check deployment ID restriction
if len(claims.DeploymentIDs) > 0 && !slices.Contains(claims.DeploymentIDs, deploymentID) {
return xerrors.Errorf("license is locked to deployments %q but this deployment is %q", claims.DeploymentIDs, deploymentID)
}
// Insert the license into the database
// Use the same system context with specific license permissions
// nolint:gocritic // This is a system operation during startup before any users exist
dl, err := db.InsertLicense(systemCtx, database.InsertLicenseParams{
UploadedAt: dbtime.Now(),
JWT: licenseStr,
Exp: claims.ExpiresAt.Time,
UUID: id,
})
if err != nil {
return xerrors.Errorf("insert license: %w", err)
}
logger.Info(ctx, "successfully imported license from file",
slog.F("path", filePath),
slog.F("license_id", dl.ID),
slog.F("license_uuid_id", dl.UUID),
)
return nil
}
+4
View File
@@ -49,6 +49,10 @@ env:
secretKeyRef:
name: {{ .Values.provisionerDaemon.pskSecretName | quote }}
key: psk
{{- end }}
{{- if .Values.coder.license.secretName }}
- name: CODER_LICENSE_FILE
value: "{{ .Values.coder.license.mountPath }}/{{ .Values.coder.license.secretKey }}"
{{- end }}
# Set the default access URL so a `helm apply` works by default.
# See: https://github.com/coder/coder/issues/5024
+13
View File
@@ -251,6 +251,19 @@ coder:
# exec:
# command: ["/bin/sh","-c","echo preStart"]
# coder.license -- License configuration for automated license import.
# The license will only be imported on first startup when no licenses exist yet.
license:
# coder.license.secretName -- Name of a Kubernetes secret containing the license.
# The secret must be in the same namespace and contain a key with the license JWT.
secretName: ""
# coder.license.secretKey -- Key in the secret that contains the license JWT.
# Defaults to "license.jwt" if not specified.
secretKey: "license.jwt"
# coder.license.mountPath -- Path where the license will be mounted inside the container.
# This path will be passed to CODER_LICENSE_FILE environment variable.
mountPath: "/tmp/coder-license"
# coder.resources -- The resources to request for Coder. The below values are
# defaults and can be overridden.
resources:
+14
View File
@@ -104,6 +104,13 @@ Coder volume definitions.
secret:
secretName: {{ $secret.name | quote }}
{{ end -}}
{{- with .Values.coder.license }}
{{- if .secretName }}
- name: "license"
secret:
secretName: {{ .secretName | quote }}
{{ end -}}
{{- end }}
{{ if gt (len .Values.coder.volumes) 0 -}}
{{ toYaml .Values.coder.volumes }}
{{ end -}}
@@ -138,6 +145,13 @@ Coder volume mounts.
subPath: {{ $secret.key | quote }}
readOnly: true
{{ end -}}
{{- with .Values.coder.license }}
{{- if .secretName }}
- name: "license"
mountPath: {{ .mountPath | quote }}
readOnly: true
{{ end -}}
{{- end }}
{{ if gt (len .Values.coder.volumeMounts) 0 -}}
{{ toYaml .Values.coder.volumeMounts }}
{{ end -}}
+1
View File
@@ -1786,6 +1786,7 @@ export interface DeploymentValues {
readonly workspace_prebuilds?: PrebuildsConfig;
readonly hide_ai_tasks?: boolean;
readonly ai?: AIConfig;
readonly license_file?: string;
readonly config?: string;
readonly write_config?: boolean;
/**