diff --git a/internal/api/keppel/accounts.go b/internal/api/keppel/accounts.go index 01131e8c..92593fc6 100644 --- a/internal/api/keppel/accounts.go +++ b/internal/api/keppel/accounts.go @@ -62,11 +62,11 @@ type Account struct { // ReplicationPolicy represents a replication policy in the API. type ReplicationPolicy struct { - Strategy string + Strategy string `json:"strategy"` //only for `on_first_use` - UpstreamPeerHostName string + UpstreamPeerHostName string `json:"upstream_peer_hostname"` //only for `from_external_on_first_use` - ExternalPeer ReplicationExternalPeerSpec + ExternalPeer ReplicationExternalPeerSpec `json:"external_peer"` } // ReplicationExternalPeerSpec appears in type ReplicationPolicy. diff --git a/internal/drivers/basic/account_management.go b/internal/drivers/basic/account_management.go new file mode 100644 index 000000000..bc1470f7 --- /dev/null +++ b/internal/drivers/basic/account_management.go @@ -0,0 +1,146 @@ +/****************************************************************************** +* +* 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" + + keppelv1 "github.com/sapcc/keppel/internal/api/keppel" + "github.com/sapcc/keppel/internal/keppel" + + "gopkg.in/yaml.v2" +) + +// AccountManagementDriver is the account management driver "basic". +type AccountManagementDriver struct { + configPath string + config AccountConfig + skipConfigLoading bool // used in tests +} + +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 keppelv1.ReplicationPolicy `yaml:"replication_policy"` + SecurityScanPolicies []keppel.SecurityScanPolicy `yaml:"security_scan_policies"` + ValidationPolicy keppelv1.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) { + if !a.skipConfigLoading { + 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..bdf82ff3 --- /dev/null +++ b/internal/drivers/basic/account_management_test.go @@ -0,0 +1,118 @@ +/****************************************************************************** +* +* 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" + "time" + + keppelv1 "github.com/sapcc/keppel/internal/api/keppel" + "github.com/sapcc/keppel/internal/keppel" + + "github.com/sapcc/go-bits/assert" +) + +func TestAccountManagementDriver(t *testing.T) { + driver := AccountManagementDriver{ + skipConfigLoading: true, + } + + driver.config.Accounts = []Accounts{ + { + Name: "abcde", + AuthTenantID: "1245", + GCPolicies: []keppel.GCPolicy{ + { + RepositoryRx: ".*/database", + NegativeRepositoryRx: "archive/.*", + TimeConstraint: &keppel.GCTimeConstraint{ + FieldName: "pushed_at", + MaxAge: keppel.Duration(6 * time.Hour), + }, + Action: "protect", + }, + { + RepositoryRx: ".*", + OnlyUntagged: true, + Action: "delete", + }, + }, + RBACPolicies: []keppel.RBACPolicy{ + { + RepositoryPattern: "library/.*", + Permissions: []keppel.RBACPermission{keppel.GrantsAnonymousPull}, + }, + { + RepositoryPattern: "library/alpine", + UserNamePattern: ".*@tenant2", + Permissions: []keppel.RBACPermission{keppel.GrantsPull, keppel.GrantsPush}, + }, + }, + ReplicationPolicy: keppelv1.ReplicationPolicy{ + Strategy: "from_external_on_first_use", + ExternalPeer: keppelv1.ReplicationExternalPeerSpec{URL: "registry-tertiary.example.org"}, + }, + SecurityScanPolicies: []keppel.SecurityScanPolicy{ + { + RepositoryRx: ".*", + VulnerabilityIDRx: ".*", + ExceptFixReleased: true, + Action: keppel.SecurityScanPolicyAction{ + Ignore: true, + Assessment: "risk accepted: vulnerabilities without an available fix are not actionable", + }, + }, + }, + ValidationPolicy: keppelv1.ValidationPolicy{ + RequiredLabels: []string{ + "important-label", + "some-label", + }, + }, + }, + } + + account := keppel.Account{ + IsManaged: true, + Name: "abcde", + AuthTenantID: "1245", + } + newAccount, err := driver.ConfigureAccount(nil, account) + if err != nil { + t.Errorf(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", expectedAccount, newAccount) + + listOfAccounts, err := driver.ManagedAccountNames() + if err != nil { + t.Errorf(err.Error()) + } + assert.DeepEqual(t, "account", listOfAccounts, []string{"abcde"}) +} diff --git a/internal/keppel/account_management_driver.go b/internal/keppel/account_management_driver.go new file mode 100644 index 000000000..b43dd6f3 --- /dev/null +++ b/internal/keppel/account_management_driver.go @@ -0,0 +1,63 @@ +/****************************************************************************** +* +* 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 ( + "errors" + + "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. + // + // Returns ErrSkipAccount if the account is not managed. + // The jobloop will leave the account unchanged. + 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(). + // + // If names stop appearing in the list, Keppel will delete the + // respective accounts (including all manifests, blobs and repos). + ManagedAccountNames() ([]string, error) +} + +// ErrSkipAccount can be returned by AccountManagementDriver.ConfigureAccount. +// See documentation over there for details. +var ErrSkipAccount = errors.New("account is not managed") + +// 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 ef48fe4d..a635400f 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/models.go b/internal/keppel/models.go index 4b71f52b..14a09498 100644 --- a/internal/keppel/models.go +++ b/internal/keppel/models.go @@ -51,6 +51,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"`