mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-08-30 00:47:16 +00:00
feat(oidc): add fine grained access control (#91)
* feat(access-control): fine grain tokens * fix(docs): clean wording * test(access-control): add tests for team extraction from access tokens and bucket info permissions * test(vocabulary): add test for ExpandGlob function to reject non-glob patterns * feat(helm): add multi-user access control documentation and schema support
This commit is contained in:
@@ -14,11 +14,12 @@ import (
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Garage GarageConfig `mapstructure:"garage"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
Server ServerConfig `mapstructure:"server"`
|
||||
Garage GarageConfig `mapstructure:"garage"`
|
||||
Auth AuthConfig `mapstructure:"auth"`
|
||||
CORS CORSConfig `mapstructure:"cors"`
|
||||
Logging LoggingConfig `mapstructure:"logging"`
|
||||
AccessControl *AccessControlConfig `mapstructure:"access_control"`
|
||||
}
|
||||
|
||||
// ServerConfig contains server-related configuration
|
||||
@@ -81,6 +82,7 @@ type OIDCConfig struct {
|
||||
UsernameAttribute string `mapstructure:"username_attribute"`
|
||||
NameAttribute string `mapstructure:"name_attribute"`
|
||||
RoleAttributePath string `mapstructure:"role_attribute_path"`
|
||||
TeamAttributePath string `mapstructure:"team_attribute_path"`
|
||||
AdminRole string `mapstructure:"admin_role"`
|
||||
AdminRoles []string `mapstructure:"admin_roles"`
|
||||
TLSSkipVerify bool `mapstructure:"tls_skip_verify"`
|
||||
@@ -130,6 +132,33 @@ type LoggingConfig struct {
|
||||
Format string `mapstructure:"format"`
|
||||
}
|
||||
|
||||
// AccessControlConfig is the optional access_control section. nil (section
|
||||
// absent) preserves historical behavior: every authenticated user is admin.
|
||||
// When present, authorization is default-deny and detailed policy validation
|
||||
// happens in internal/authz.CompilePolicy at startup.
|
||||
// This section is config-file only (no env-var binding: nested lists don't
|
||||
// map to flat env vars).
|
||||
type AccessControlConfig struct {
|
||||
Presets map[string][]string `mapstructure:"presets"`
|
||||
Teams []TeamConfig `mapstructure:"teams"`
|
||||
}
|
||||
|
||||
// TeamConfig binds a set of IdP claim values to bucket-prefix bindings and
|
||||
// cluster-level permissions.
|
||||
type TeamConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
ClaimValues []string `mapstructure:"claim_values"`
|
||||
Bindings []BindingConfig `mapstructure:"bindings"`
|
||||
ClusterPermissions []string `mapstructure:"cluster_permissions"`
|
||||
}
|
||||
|
||||
// BindingConfig grants a set of permissions (or presets) over buckets whose
|
||||
// names match one of the given prefixes.
|
||||
type BindingConfig struct {
|
||||
BucketPrefixes []string `mapstructure:"bucket_prefixes"`
|
||||
Permissions []string `mapstructure:"permissions"`
|
||||
}
|
||||
|
||||
// LoadOption configures optional behaviour of Load.
|
||||
type LoadOption func(*loadOptions)
|
||||
|
||||
@@ -216,6 +245,16 @@ func Load(configPath string, opts ...LoadOption) (*Config, error) {
|
||||
return nil, fmt.Errorf("error unmarshaling config: %w", err)
|
||||
}
|
||||
|
||||
// mapstructure leaves AccessControl nil when the section is present but
|
||||
// decodes to an empty map (e.g. "access_control: {}"), even though
|
||||
// viper.IsSet still reports it present. AccessControlConfig's documented
|
||||
// semantics are presence-based, not content-based ("nil = absent =
|
||||
// historical behavior"; "present, even empty = enabled default-deny"),
|
||||
// so force allocation here rather than silently falling back to nil.
|
||||
if cfg.AccessControl == nil && viper.IsSet("access_control") {
|
||||
cfg.AccessControl = &AccessControlConfig{}
|
||||
}
|
||||
|
||||
// Validate the configuration
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("invalid configuration: %w", err)
|
||||
@@ -269,6 +308,7 @@ func bindEnvVars() {
|
||||
viper.BindEnv("auth.oidc.username_attribute", "GARAGE_UI_AUTH_OIDC_USERNAME_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.name_attribute", "GARAGE_UI_AUTH_OIDC_NAME_ATTRIBUTE")
|
||||
viper.BindEnv("auth.oidc.role_attribute_path", "GARAGE_UI_AUTH_OIDC_ROLE_ATTRIBUTE_PATH")
|
||||
viper.BindEnv("auth.oidc.team_attribute_path", "GARAGE_UI_AUTH_OIDC_TEAM_ATTRIBUTE_PATH")
|
||||
viper.BindEnv("auth.oidc.admin_role", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLE")
|
||||
viper.BindEnv("auth.oidc.admin_roles", "GARAGE_UI_AUTH_OIDC_ADMIN_ROLES")
|
||||
viper.BindEnv("auth.oidc.tls_skip_verify", "GARAGE_UI_AUTH_OIDC_TLS_SKIP_VERIFY")
|
||||
@@ -374,13 +414,15 @@ func (c *Config) Validate() error {
|
||||
if len(c.Auth.OIDC.Scopes) == 0 {
|
||||
return fmt.Errorf("oidc scopes are required when oidc is enabled")
|
||||
}
|
||||
// Every authenticated route on this service grants full admin
|
||||
// access — there is no separate authorization layer. Empty
|
||||
// admin role configuration would therefore promote every user
|
||||
// in the IdP realm to cluster admin. Require operators to opt
|
||||
// in explicitly via admin_role or admin_roles.
|
||||
if len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
|
||||
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled: leaving them empty would grant cluster-admin access to any authenticated IdP user")
|
||||
// With access_control configured, default-deny protects unmatched
|
||||
// users, so admin roles become optional. Without it, every
|
||||
// authenticated route grants full admin access, so an empty admin
|
||||
// role list would promote every IdP user to cluster admin.
|
||||
if c.AccessControl == nil && len(c.Auth.OIDC.EffectiveAdminRoles()) == 0 {
|
||||
return fmt.Errorf("oidc admin_role or admin_roles is required when oidc is enabled without access_control: leaving them empty would grant cluster-admin access to any authenticated IdP user")
|
||||
}
|
||||
if c.AccessControl != nil && len(c.AccessControl.Teams) > 0 && c.Auth.OIDC.TeamAttributePath == "" {
|
||||
return fmt.Errorf("auth.oidc.team_attribute_path is required when access_control.teams is set: teams cannot be resolved without it")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -329,6 +329,17 @@ func TestValidate(t *testing.T) {
|
||||
mutate: applyValidOIDC,
|
||||
wantErrContains: "",
|
||||
},
|
||||
{
|
||||
name: "access_control teams without team_attribute_path rejected",
|
||||
mutate: func(c *Config) {
|
||||
applyValidOIDC(c)
|
||||
c.Auth.OIDC.TeamAttributePath = ""
|
||||
c.AccessControl = &AccessControlConfig{
|
||||
Teams: []TeamConfig{{Name: "t", ClaimValues: []string{"g"}}},
|
||||
}
|
||||
},
|
||||
wantErrContains: "team_attribute_path is required",
|
||||
},
|
||||
{
|
||||
name: "oidc disabled ignores missing client_id",
|
||||
mutate: func(c *Config) {
|
||||
@@ -736,6 +747,133 @@ func TestLoad_FileBackedEnvVarMissingFileReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControlConfigParsing(t *testing.T) {
|
||||
resetViper(t)
|
||||
dir := t.TempDir()
|
||||
cfgFile := filepath.Join(dir, "config.yaml")
|
||||
yaml := `
|
||||
server:
|
||||
port: 8080
|
||||
garage:
|
||||
endpoint: "http://localhost:3900"
|
||||
admin_endpoint: "http://localhost:3903"
|
||||
admin_token: "test-token"
|
||||
auth:
|
||||
oidc:
|
||||
enabled: false
|
||||
team_attribute_path: "groups"
|
||||
access_control:
|
||||
presets:
|
||||
bucket_readonly: [bucket.list, bucket.read]
|
||||
teams:
|
||||
- name: backend
|
||||
claim_values: ["garage-team-backend"]
|
||||
bindings:
|
||||
- bucket_prefixes: ["backend-"]
|
||||
permissions: ["preset:bucket_readonly", "bucket.create"]
|
||||
cluster_permissions: [cluster.status]
|
||||
`
|
||||
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AccessControl == nil {
|
||||
t.Fatal("AccessControl is nil, want parsed section")
|
||||
}
|
||||
if got := cfg.Auth.OIDC.TeamAttributePath; got != "groups" {
|
||||
t.Errorf("TeamAttributePath = %q, want groups", got)
|
||||
}
|
||||
if len(cfg.AccessControl.Teams) != 1 {
|
||||
t.Fatalf("teams = %d, want 1", len(cfg.AccessControl.Teams))
|
||||
}
|
||||
team := cfg.AccessControl.Teams[0]
|
||||
if team.Name != "backend" || len(team.Bindings) != 1 {
|
||||
t.Errorf("unexpected team: %+v", team)
|
||||
}
|
||||
if team.Bindings[0].BucketPrefixes[0] != "backend-" {
|
||||
t.Errorf("prefix = %q", team.Bindings[0].BucketPrefixes[0])
|
||||
}
|
||||
if cfg.AccessControl.Presets["bucket_readonly"][0] != "bucket.list" {
|
||||
t.Errorf("preset parse failed: %+v", cfg.AccessControl.Presets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControlAbsentIsNil(t *testing.T) {
|
||||
resetViper(t)
|
||||
dir := t.TempDir()
|
||||
cfgFile := filepath.Join(dir, "config.yaml")
|
||||
yaml := `
|
||||
garage:
|
||||
endpoint: "http://localhost:3900"
|
||||
admin_endpoint: "http://localhost:3903"
|
||||
admin_token: "test-token"
|
||||
`
|
||||
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AccessControl != nil {
|
||||
t.Fatalf("AccessControl = %+v, want nil when section absent", cfg.AccessControl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControlPresentButEmptyIsNonNil(t *testing.T) {
|
||||
// A present-but-empty access_control section pins the enablement
|
||||
// semantics documented on AccessControlConfig: presence, not content,
|
||||
// turns on default-deny. An operator who writes "access_control: {}"
|
||||
// (e.g. while staging a config) must get a non-nil, enabled policy, not
|
||||
// silently fall back to "every authenticated user is admin".
|
||||
resetViper(t)
|
||||
dir := t.TempDir()
|
||||
cfgFile := filepath.Join(dir, "config.yaml")
|
||||
yaml := `
|
||||
garage:
|
||||
endpoint: "http://localhost:3900"
|
||||
admin_endpoint: "http://localhost:3903"
|
||||
admin_token: "test-token"
|
||||
access_control: {}
|
||||
`
|
||||
if err := os.WriteFile(cfgFile, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(cfgFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.AccessControl == nil {
|
||||
t.Fatal("AccessControl = nil, want non-nil when section is present but empty")
|
||||
}
|
||||
if len(cfg.AccessControl.Teams) != 0 {
|
||||
t.Errorf("Teams = %+v, want empty", cfg.AccessControl.Teams)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCAdminRolesOptionalWithAccessControl(t *testing.T) {
|
||||
// With access_control present, OIDC no longer requires admin_role:
|
||||
// default-deny protects unmatched users.
|
||||
cfg := &Config{
|
||||
Server: ServerConfig{Port: 8080, RootURL: "https://ui.example.com"},
|
||||
Garage: GarageConfig{Endpoint: "e", AdminEndpoint: "a", AdminToken: "t"},
|
||||
Auth: AuthConfig{OIDC: OIDCConfig{
|
||||
Enabled: true, ClientID: "id", IssuerURL: "https://idp", Scopes: []string{"openid"},
|
||||
}},
|
||||
AccessControl: &AccessControlConfig{},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Errorf("Validate with access_control and no admin_role: %v, want nil", err)
|
||||
}
|
||||
cfg.AccessControl = nil
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Error("Validate without access_control and no admin_role should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProduction(t *testing.T) {
|
||||
tests := []struct {
|
||||
env string
|
||||
|
||||
Reference in New Issue
Block a user