diff --git a/internal/drivers/basic/account_management.go b/internal/drivers/basic/account_management.go new file mode 100644 index 000000000..b76f5f2d --- /dev/null +++ b/internal/drivers/basic/account_management.go @@ -0,0 +1,142 @@ +/****************************************************************************** +* +* Copyright 2024 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package basic + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "github.com/sapcc/keppel/internal/keppel" + + "gopkg.in/yaml.v2" +) + +// AccountManagementDriver is the account management driver "basic". +type AccountManagementDriver struct { + configPath string + config AccountConfig +} + +type AccountConfig struct { + Accounts []Accounts `yaml:"accounts"` +} + +type Accounts struct { + Name string `yaml:"name"` + AuthTenantID string `yaml:"auth_tenant_id"` + GCPolicies []keppel.GCPolicy `yaml:"gc_policies"` + RBACPolicies []keppel.RBACPolicy `yaml:"rbac_policies"` + ReplicationPolicy keppel.ReplicationPolicy `yaml:"replication_policy"` + SecurityScanPolicies []keppel.SecurityScanPolicy `yaml:"security_scan_policies"` + ValidationPolicy keppel.ValidationPolicy `yaml:"validation_policy"` +} + +func init() { + keppel.AccountManagementDriverRegistry.Add(func() keppel.AccountManagementDriver { + return &AccountManagementDriver{} + }) +} + +// PluginTypeID implements the keppel.AccountManagementDriver interface. +func (a *AccountManagementDriver) PluginTypeID() string { return "basic" } + +// ConfigureAccount implements the keppel.AccountManagementDriver interface. +func (a *AccountManagementDriver) Init(envToConfigFile string) error { + a.configPath = os.Getenv("KEPPEL_ACCOUNT_MANAGEMENT_FILE") + if a.configPath == "" { + return errors.New("KEPPEL_ACCOUNT_MANAGEMENT_FILE is not set") + } + + return a.loadConfig() +} + +// ConfigureAccount implements the keppel.AccountManagementDriver interface. +func (a *AccountManagementDriver) ConfigureAccount(db *keppel.DB, account keppel.Account) (keppel.Account, error) { + for _, cfgAccount := range a.config.Accounts { + if cfgAccount.AuthTenantID != account.AuthTenantID { + continue + } + + account.IsManaged = true + account.RequiredLabels = strings.Join(cfgAccount.ValidationPolicy.RequiredLabels, ",") + + gcPolicyJSON, err := json.Marshal(cfgAccount.GCPolicies) + if err != nil { + return keppel.Account{}, fmt.Errorf("gc_policies: %w", err) + } + account.GCPoliciesJSON = string(gcPolicyJSON) + + rbacPolicyJSON, err := json.Marshal(cfgAccount.RBACPolicies) + if err != nil { + return keppel.Account{}, fmt.Errorf("rbac_policies: %w", err) + } + account.RBACPoliciesJSON = string(rbacPolicyJSON) + + securityScanPoliciesJSON, err := json.Marshal(cfgAccount.SecurityScanPolicies) + if err != nil { + return keppel.Account{}, fmt.Errorf("security_scan_policies: %w", err) + } + account.SecurityScanPoliciesJSON = string(securityScanPoliciesJSON) + + _, err = cfgAccount.ReplicationPolicy.ApplyToAccount(db, &account) + if err != nil { + return keppel.Account{}, fmt.Errorf("replication_policy: %w", err) + } + + return account, nil + } + + // we didn't find the account, delete it + return keppel.Account{}, nil +} + +func (a *AccountManagementDriver) ManagedAccountNames() ([]string, error) { + err := a.loadConfig() + if err != nil { + return nil, err + } + + var accounts []string + for _, account := range a.config.Accounts { + accounts = append(accounts, account.Name) + } + + return accounts, nil +} + +func (a *AccountManagementDriver) loadConfig() error { + reader, err := os.Open(a.configPath) + if err != nil { + return err + } + + decoder := yaml.NewDecoder(reader) + decoder.SetStrict(true) + var config AccountConfig + err = decoder.Decode(&config) + if err != nil { + return err + } + + a.config = config + return nil +} diff --git a/internal/drivers/basic/account_management_test.go b/internal/drivers/basic/account_management_test.go new file mode 100644 index 000000000..c08dd61a --- /dev/null +++ b/internal/drivers/basic/account_management_test.go @@ -0,0 +1,61 @@ +/****************************************************************************** +* +* Copyright 2024 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package basic + +import ( + "testing" + + "github.com/sapcc/keppel/internal/keppel" + + "github.com/sapcc/go-bits/assert" +) + +func TestAccountManagementDriver(t *testing.T) { + driver := AccountManagementDriver{ + configPath: "./fixtures/account_management.yaml", + } + + listOfAccounts, err := driver.ManagedAccountNames() + if err != nil { + t.Fatalf(err.Error()) + } + assert.DeepEqual(t, "account", listOfAccounts, []string{"abcde"}) + + account := keppel.Account{ + IsManaged: true, + Name: "abcde", + AuthTenantID: "1245", + } + newAccount, err := driver.ConfigureAccount(nil, account) + if err != nil { + t.Fatalf(err.Error()) + } + + expectedAccount := keppel.Account{ + Name: "abcde", + AuthTenantID: "1245", + ExternalPeerURL: "registry-tertiary.example.org", + RequiredLabels: "important-label,some-label", + IsManaged: true, + RBACPoliciesJSON: "[{\"match_repository\":\"library/.*\",\"permissions\":[\"anonymous_pull\"]},{\"match_repository\":\"library/alpine\",\"match_username\":\".*@tenant2\",\"permissions\":[\"pull\",\"push\"]}]", + GCPoliciesJSON: "[{\"match_repository\":\".*/database\",\"except_repository\":\"archive/.*\",\"time_constraint\":{\"on\":\"pushed_at\",\"newer_than\":{\"value\":6,\"unit\":\"h\"}},\"action\":\"protect\"},{\"match_repository\":\".*\",\"only_untagged\":true,\"action\":\"delete\"}]", + SecurityScanPoliciesJSON: "[{\"match_repository\":\".*\",\"match_vulnerability_id\":\".*\",\"except_fix_released\":true,\"action\":{\"assessment\":\"risk accepted: vulnerabilities without an available fix are not actionable\",\"ignore\":true}}]", + } + assert.DeepEqual(t, "account", newAccount, expectedAccount) +} diff --git a/internal/drivers/basic/fixtures/account_management.yaml b/internal/drivers/basic/fixtures/account_management.yaml new file mode 100644 index 000000000..68157dad --- /dev/null +++ b/internal/drivers/basic/fixtures/account_management.yaml @@ -0,0 +1,38 @@ +accounts: +- name: abcde + auth_tenant_id: "1245" + gc_policies: + - match_repository: .*/database + except_repository: archive/.* + time_constraint: + "on": pushed_at + newer_than: 21600000000000 + action: protect + - match_repository: .* + only_untagged: true + action: delete + rbac_policies: + - match_repository: library/.* + permissions: + - anonymous_pull + - match_repository: library/alpine + match_username: .*@tenant2 + permissions: + - pull + - push + replication_policy: + strategy: from_external_on_first_use + upstream_peer_hostname: "" + external_peer: + url: registry-tertiary.example.org + security_scan_policies: + - match_repository: .* + match_vulnerability_id: .* + except_fix_released: true + action: + assessment: 'risk accepted: vulnerabilities without an available fix are not actionable' + ignore: true + validation_policy: + required_labels: + - important-label + - some-label diff --git a/internal/keppel/account_management_driver.go b/internal/keppel/account_management_driver.go new file mode 100644 index 000000000..bc5c795d --- /dev/null +++ b/internal/keppel/account_management_driver.go @@ -0,0 +1,51 @@ +/****************************************************************************** +* +* Copyright 2024 SAP SE +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +* +******************************************************************************/ + +package keppel + +import ( + "github.com/sapcc/go-bits/pluggable" +) + +// AccountManagementDriver is a pluggable interface for receiving account +// configuration from an external system. Accounts can either be managed by +// this driver, or created and maintained by users through the Keppel API. +type AccountManagementDriver interface { + pluggable.Plugin + // Init is called before any other interface methods, and allows the plugin to + // perform first-time initialization. + Init(string) error + + // Called by a jobloop for every account every once in a while (e.g. every hour). + // + // Returns an updated account object if the account is managed. + // The jobloop will apply the account in the DB accordingly. + // + // Returns Account{} if the account was managed, and now shall be deleted. + // The jobloop will clean up the manifests, blobs, repos and the account. + ConfigureAccount(*DB, Account) (Account, error) + + // Called by a jobloop every once in a while (e.g. every hour). + // + // If new names appear in the list, the jobloop will create the + // respective accounts as configured by ConfigureAccount(). + ManagedAccountNames() ([]string, error) +} + +// AccountManagementDriverRegistry is a pluggable.Registry for AccountManagementDriver implementations. +var AccountManagementDriverRegistry pluggable.Registry[AccountManagementDriver] diff --git a/internal/keppel/database.go b/internal/keppel/database.go index 90127808..7cd9fa7e 100644 --- a/internal/keppel/database.go +++ b/internal/keppel/database.go @@ -249,6 +249,14 @@ var sqlMigrations = map[string]string{ PRIMARY KEY (account_name, match_cidr, match_repository, match_username) ); `, + "038_add_accounts_is_managed.up.sql": ` + ALTER TABLE accounts + ADD COLUMN is_managed BOOLEAN NOT NULL DEFAULT FALSE; + `, + "038_add_accounts_is_managed.down.sql": ` + ALTER TABLE accounts + DROP COLUMN is_managed; + `, } // DB adds convenience functions on top of gorp.DbMap. diff --git a/internal/keppel/gc_policy.go b/internal/keppel/gc_policy.go index 2152dc3e..2e819d47 100644 --- a/internal/keppel/gc_policy.go +++ b/internal/keppel/gc_policy.go @@ -33,22 +33,22 @@ import ( // GCPolicy is a policy enabling optional garbage collection runs in an account. // It is stored in serialized form in the GCPoliciesJSON field of type Account. type GCPolicy struct { - RepositoryRx regexpext.BoundedRegexp `json:"match_repository"` - NegativeRepositoryRx regexpext.BoundedRegexp `json:"except_repository,omitempty"` - TagRx regexpext.BoundedRegexp `json:"match_tag,omitempty"` - NegativeTagRx regexpext.BoundedRegexp `json:"except_tag,omitempty"` - OnlyUntagged bool `json:"only_untagged,omitempty"` - TimeConstraint *GCTimeConstraint `json:"time_constraint,omitempty"` - Action string `json:"action"` + RepositoryRx regexpext.BoundedRegexp `json:"match_repository" yaml:"match_repository"` + NegativeRepositoryRx regexpext.BoundedRegexp `json:"except_repository,omitempty" yaml:"except_repository,omitempty"` + TagRx regexpext.BoundedRegexp `json:"match_tag,omitempty" yaml:"match_tag,omitempty"` + NegativeTagRx regexpext.BoundedRegexp `json:"except_tag,omitempty" yaml:"except_tag,omitempty"` + OnlyUntagged bool `json:"only_untagged,omitempty" yaml:"only_untagged,omitempty"` + TimeConstraint *GCTimeConstraint `json:"time_constraint,omitempty" yaml:"time_constraint,omitempty"` + Action string `json:"action" yaml:"action"` } // GCTimeConstraint appears in type GCPolicy. type GCTimeConstraint struct { - FieldName string `json:"on"` - OldestCount uint64 `json:"oldest,omitempty"` - NewestCount uint64 `json:"newest,omitempty"` - MinAge Duration `json:"older_than,omitempty"` - MaxAge Duration `json:"newer_than,omitempty"` + FieldName string `json:"on" yaml:"on"` + OldestCount uint64 `json:"oldest,omitempty" yaml:"oldest,omitempty"` + NewestCount uint64 `json:"newest,omitempty" yaml:"newest,omitempty"` + MinAge Duration `json:"older_than,omitempty" yaml:"older_than,omitempty"` + MaxAge Duration `json:"newer_than,omitempty" yaml:"newer_than,omitempty"` } // MatchesRepository evaluates the repository regexes in this policy. diff --git a/internal/keppel/models.go b/internal/keppel/models.go index 5d211f5c..f6477048 100644 --- a/internal/keppel/models.go +++ b/internal/keppel/models.go @@ -52,6 +52,8 @@ type Account struct { RequiredLabels string `db:"required_labels"` // InMaintenance indicates whether the account is in maintenance mode (as defined in the API spec). InMaintenance bool `db:"in_maintenance"` + // IsManaged indicates if the account was created by AccountManagementDriver + IsManaged bool `db:"is_managed"` // MetadataJSON contains a JSON string of a map[string]string, or the empty string. MetadataJSON string `db:"metadata_json"` diff --git a/internal/keppel/rbac_policy.go b/internal/keppel/rbac_policy.go index 6f266e41..5550dfb6 100644 --- a/internal/keppel/rbac_policy.go +++ b/internal/keppel/rbac_policy.go @@ -31,10 +31,10 @@ import ( // RBACPolicy is a policy granting user-defined access to repos in an account. // It is stored in serialized form in the RBACPoliciesJSON field of type Account. type RBACPolicy struct { - CidrPattern string `json:"match_cidr,omitempty"` - RepositoryPattern regexpext.BoundedRegexp `json:"match_repository,omitempty"` - UserNamePattern regexpext.BoundedRegexp `json:"match_username,omitempty"` - Permissions []RBACPermission `json:"permissions"` + CidrPattern string `json:"match_cidr,omitempty" yaml:"match_cidr,omitempty"` + RepositoryPattern regexpext.BoundedRegexp `json:"match_repository,omitempty" yaml:"match_repository,omitempty"` + UserNamePattern regexpext.BoundedRegexp `json:"match_username,omitempty" yaml:"match_username,omitempty"` + Permissions []RBACPermission `json:"permissions" yaml:"permissions"` } // RBACPermission enumerates permissions that can be granted by an RBAC policy. diff --git a/internal/keppel/replication.go b/internal/keppel/replication.go index 90770bce..65680322 100644 --- a/internal/keppel/replication.go +++ b/internal/keppel/replication.go @@ -27,18 +27,18 @@ import ( // ReplicationPolicy represents a replication policy in the API. type ReplicationPolicy struct { - Strategy string `json:"strategy"` - //only for `on_first_use` - UpstreamPeerHostName string `json:"upstream_peer_hostname"` - //only for `from_external_on_first_use` - ExternalPeer ReplicationExternalPeerSpec `json:"external_peer"` + Strategy string `json:"strategy" yaml:"strategy"` + // only for `on_first_use` + UpstreamPeerHostName string `json:"upstream_peer_hostname" yaml:"upstream_peer_hostname"` + // only for `from_external_on_first_use` + ExternalPeer ReplicationExternalPeerSpec `json:"external_peer" yaml:"external_peer"` } // ReplicationExternalPeerSpec appears in type ReplicationPolicy. type ReplicationExternalPeerSpec struct { - URL string `json:"url"` - UserName string `json:"username,omitempty"` - Password string `json:"password,omitempty"` + URL string `json:"url" yaml:"url"` + UserName string `json:"username,omitempty" yaml:"username,omitempty"` + Password string `json:"password,omitempty" yaml:"password,omitempty"` } // MarshalJSON implements the json.Marshaler interface. diff --git a/internal/keppel/security_scan_policy.go b/internal/keppel/security_scan_policy.go index bbcacec1..768c0cc2 100644 --- a/internal/keppel/security_scan_policy.go +++ b/internal/keppel/security_scan_policy.go @@ -36,20 +36,20 @@ import ( type SecurityScanPolicy struct { //NOTE: We have code that uses slices.Contains() to locate policies. Be careful // when adding fields that cannot be meaningfully compared with the == operator. - ManagingUserName string `json:"managed_by_user,omitempty"` - RepositoryRx regexpext.BoundedRegexp `json:"match_repository"` - NegativeRepositoryRx regexpext.BoundedRegexp `json:"except_repository,omitempty"` - VulnerabilityIDRx regexpext.BoundedRegexp `json:"match_vulnerability_id"` - NegativeVulnerabilityIDRx regexpext.BoundedRegexp `json:"except_vulnerability_id,omitempty"` - ExceptFixReleased bool `json:"except_fix_released,omitempty"` - Action SecurityScanPolicyAction `json:"action"` + ManagingUserName string `json:"managed_by_user,omitempty" yaml:"managed_by_user,omitempty"` + RepositoryRx regexpext.BoundedRegexp `json:"match_repository" yaml:"match_repository"` + NegativeRepositoryRx regexpext.BoundedRegexp `json:"except_repository,omitempty" yaml:"except_repository,omitempty"` + VulnerabilityIDRx regexpext.BoundedRegexp `json:"match_vulnerability_id" yaml:"match_vulnerability_id"` + NegativeVulnerabilityIDRx regexpext.BoundedRegexp `json:"except_vulnerability_id,omitempty" yaml:"except_vulnerability_id,omitempty"` + ExceptFixReleased bool `json:"except_fix_released,omitempty" yaml:"except_fix_released,omitempty"` + Action SecurityScanPolicyAction `json:"action" yaml:"action"` } // SecurityScanPolicyAction appears in type SecurityScanPolicy. type SecurityScanPolicyAction struct { - Assessment string `json:"assessment"` - Ignore bool `json:"ignore,omitempty"` - Severity trivy.VulnerabilityStatus `json:"severity,omitempty"` + Assessment string `json:"assessment" yaml:"assessment"` + Ignore bool `json:"ignore,omitempty" yaml:"ignore,omitempty"` + Severity trivy.VulnerabilityStatus `json:"severity,omitempty" yaml:"severity,omitempty"` } // String returns the JSON representation of this policy (for use in log and diff --git a/internal/keppel/validation.go b/internal/keppel/validation.go index ed760472..21abf401 100644 --- a/internal/keppel/validation.go +++ b/internal/keppel/validation.go @@ -22,7 +22,7 @@ import "strings" // ValidationPolicy represents a validation policy in the API. type ValidationPolicy struct { - RequiredLabels []string `json:"required_labels,omitempty"` + RequiredLabels []string `json:"required_labels,omitempty" yaml:"required_labels,omitempty"` } func (v ValidationPolicy) JoinRequiredLabels() string {