diff --git a/internal/api/keppel/accounts.go b/internal/api/keppel/accounts.go index 0bd0116e..3ebce10a 100644 --- a/internal/api/keppel/accounts.go +++ b/internal/api/keppel/accounts.go @@ -39,8 +39,6 @@ import ( "github.com/sapcc/go-bits/respondwith" "github.com/sapcc/go-bits/sqlext" - "github.com/sapcc/keppel/internal/auth" - peerclient "github.com/sapcc/keppel/internal/client/peer" "github.com/sapcc/keppel/internal/keppel" "github.com/sapcc/keppel/internal/models" ) @@ -93,34 +91,12 @@ func (a *API) renderAccount(dbAccount models.Account) (Account, error) { InMaintenance: dbAccount.InMaintenance, Metadata: metadata, RBACPolicies: rbacPolicies, - ReplicationPolicy: renderReplicationPolicy(dbAccount), + ReplicationPolicy: keppel.RenderReplicationPolicy(dbAccount), ValidationPolicy: keppel.RenderValidationPolicy(dbAccount), PlatformFilter: dbAccount.PlatformFilter, }, nil } -func renderReplicationPolicy(dbAccount models.Account) *keppel.ReplicationPolicy { - if dbAccount.UpstreamPeerHostName != "" { - return &keppel.ReplicationPolicy{ - Strategy: "on_first_use", - UpstreamPeerHostName: dbAccount.UpstreamPeerHostName, - } - } - - if dbAccount.ExternalPeerURL != "" { - return &keppel.ReplicationPolicy{ - Strategy: "from_external_on_first_use", - ExternalPeer: keppel.ReplicationExternalPeerSpec{ - URL: dbAccount.ExternalPeerURL, - UserName: dbAccount.ExternalPeerUserName, - //NOTE: Password is omitted here for security reasons - }, - } - } - - return nil -} - //////////////////////////////////////////////////////////////////////////////// // handlers @@ -200,7 +176,7 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { } // we do not allow to set name in the request body ... if req.Account.Name != "" { - http.Error(w, `malformed attribute "account.name" in request body is no allowed here`, http.StatusUnprocessableEntity) + http.Error(w, `malformed attribute "account.name" in request body is not allowed here`, http.StatusUnprocessableEntity) return } // ... transfer it here into the struct, to make the below code simpler @@ -225,66 +201,120 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { return } - for _, policy := range req.Account.GCPolicies { - err := policy.Validate() - if err != nil { - http.Error(w, err.Error(), http.StatusUnprocessableEntity) - return - } + // check permission to create account + authz := a.authenticateRequest(w, r, authTenantScope(keppel.CanChangeAccount, req.Account.AuthTenantID)) + if authz == nil { + return } - for idx, policy := range req.Account.RBACPolicies { - err := policy.ValidateAndNormalize() - if err != nil { - http.Error(w, err.Error(), http.StatusUnprocessableEntity) - return - } - req.Account.RBACPolicies[idx] = policy + // check if account already exists + originalAccount, err := keppel.FindAccount(a.db, req.Account.Name) + if respondwith.ErrorText(w, err) { + return + } + if originalAccount != nil && originalAccount.AuthTenantID != req.Account.AuthTenantID { + http.Error(w, `account name already in use by a different tenant`, http.StatusConflict) + return } - metadataJSONStr := "" - if len(req.Account.Metadata) > 0 { - metadataJSON, _ := json.Marshal(req.Account.Metadata) - metadataJSONStr = string(metadataJSON) + // PUT can either create a new account or update an existing account; + // this distinction is important because several fields can only be set at creation + var targetAccount models.Account + if originalAccount == nil { + targetAccount = models.Account{ + Name: req.Account.Name, + AuthTenantID: req.Account.AuthTenantID, + SecurityScanPoliciesJSON: "[]", + // all other attributes are set below or in the ApplyToAccount() methods called below + } + } else { + targetAccount = *originalAccount } - gcPoliciesJSONStr := "[]" - if len(req.Account.GCPolicies) > 0 { - gcPoliciesJSON, _ := json.Marshal(req.Account.GCPolicies) - gcPoliciesJSONStr = string(gcPoliciesJSON) + // validate and update fields as requested + targetAccount.InMaintenance = req.Account.InMaintenance + + // validate GC policies + if len(req.Account.GCPolicies) == 0 { + targetAccount.GCPoliciesJSON = "[]" + } else { + for _, policy := range req.Account.GCPolicies { + err := policy.Validate() + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + } + buf, _ := json.Marshal(req.Account.GCPolicies) + targetAccount.GCPoliciesJSON = string(buf) } - rbacPoliciesJSONStr := "" - if len(req.Account.RBACPolicies) > 0 { - rbacPoliciesJSON, _ := json.Marshal(req.Account.RBACPolicies) - rbacPoliciesJSONStr = string(rbacPoliciesJSON) + // serialize metadata + if len(req.Account.Metadata) == 0 { + targetAccount.MetadataJSON = "" + } else { + buf, _ := json.Marshal(req.Account.Metadata) + targetAccount.MetadataJSON = string(buf) } - accountToCreate := models.Account{ - Name: req.Account.Name, - AuthTenantID: req.Account.AuthTenantID, - InMaintenance: req.Account.InMaintenance, - MetadataJSON: metadataJSONStr, - GCPoliciesJSON: gcPoliciesJSONStr, - RBACPoliciesJSON: rbacPoliciesJSONStr, - SecurityScanPoliciesJSON: "[]", + // validate replication policy (for OnFirstUseStrategy, the peer hostname is + // checked for correctness down below when validating the platform filter) + var originalStrategy keppel.ReplicationStrategy + if originalAccount != nil { + rp := keppel.RenderReplicationPolicy(*originalAccount) + if rp == nil { + originalStrategy = keppel.NoReplicationStrategy + } else { + originalStrategy = rp.Strategy + } } - // validate replication policy - if req.Account.ReplicationPolicy != nil { + var replicationStrategy keppel.ReplicationStrategy + if req.Account.ReplicationPolicy == nil { + if originalAccount == nil { + replicationStrategy = keppel.NoReplicationStrategy + } else { + // PUT on existing account can omit replication policy to reuse existing policy + replicationStrategy = originalStrategy + } + } else { + // on existing accounts, we do not allow changing the strategy rp := *req.Account.ReplicationPolicy + if originalAccount != nil && originalStrategy != rp.Strategy { + http.Error(w, keppel.ErrIncompatibleReplicationPolicy.Error(), http.StatusConflict) + return + } - rerr := rp.ApplyToAccount(a.db, &accountToCreate) - if rerr != nil { - rerr.WriteAsTextTo(w) + err := rp.ApplyToAccount(&targetAccount) + if errors.Is(err, keppel.ErrIncompatibleReplicationPolicy) { + http.Error(w, err.Error(), http.StatusConflict) return + } else if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + replicationStrategy = rp.Strategy + } + + // validate RBAC policies + if len(req.Account.RBACPolicies) == 0 { + targetAccount.RBACPoliciesJSON = "" + } else { + for idx, policy := range req.Account.RBACPolicies { + err := policy.ValidateAndNormalize(replicationStrategy) + if err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + req.Account.RBACPolicies[idx] = policy } - //NOTE: There are some delayed checks below which require the existing account to be loaded from the DB first. + buf, _ := json.Marshal(req.Account.RBACPolicies) + targetAccount.RBACPoliciesJSON = string(buf) } // validate validation policy if req.Account.ValidationPolicy != nil { - rerr := req.Account.ValidationPolicy.ApplyToAccount(&accountToCreate) + rerr := req.Account.ValidationPolicy.ApplyToAccount(&targetAccount) if rerr != nil { rerr.WriteAsTextTo(w) return @@ -292,87 +322,53 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { } // validate platform filter - if req.Account.PlatformFilter != nil { - if req.Account.ReplicationPolicy == nil { - http.Error(w, `platform filter is only allowed on replica accounts`, http.StatusUnprocessableEntity) + if originalAccount != nil { + if req.Account.PlatformFilter != nil && !originalAccount.PlatformFilter.IsEqualTo(req.Account.PlatformFilter) { + http.Error(w, `cannot change platform filter on existing account`, http.StatusConflict) return } - accountToCreate.PlatformFilter = req.Account.PlatformFilter - } - - // check permission to create account - authz := a.authenticateRequest(w, r, authTenantScope(keppel.CanChangeAccount, accountToCreate.AuthTenantID)) - if authz == nil { - return - } - - // check if account already exists - account, err := keppel.FindAccount(a.db, req.Account.Name) - if respondwith.ErrorText(w, err) { - return - } - if account != nil && account.AuthTenantID != req.Account.AuthTenantID { - http.Error(w, `account name already in use by a different tenant`, http.StatusConflict) - return - } - - // late replication policy validations (could not do these earlier because we - // did not have `account` yet) - if req.Account.ReplicationPolicy != nil { - rp := *req.Account.ReplicationPolicy - - if rp.Strategy == "from_external_on_first_use" { - // for new accounts, we need either full credentials or none - if account == nil { - if (rp.ExternalPeer.UserName == "") != (rp.ExternalPeer.Password == "") { - http.Error(w, `need either both username and password or neither for "from_external_on_first_use" replication`, http.StatusUnprocessableEntity) - return - } + } else { + switch replicationStrategy { + case keppel.NoReplicationStrategy: + if req.Account.PlatformFilter != nil { + http.Error(w, `platform filter is only allowed on replica accounts`, http.StatusUnprocessableEntity) + return } - - // for existing accounts, having only a username is acceptable if it's - // unchanged (this case occurs when a client GETs the account, changes - // something unrelated to replication, and PUTs the result; the password is - // redacted in GET) - if account != nil && rp.ExternalPeer.UserName != "" && rp.ExternalPeer.Password == "" { - if rp.ExternalPeer.UserName == account.ExternalPeerUserName { - rp.ExternalPeer.Password = account.ExternalPeerPassword // to pass the equality checks below - } else { - http.Error(w, `cannot change username for "from_external_on_first_use" replication without also changing password`, http.StatusUnprocessableEntity) - return - } + case keppel.FromExternalOnFirstUseStrategy: + targetAccount.PlatformFilter = req.Account.PlatformFilter + case keppel.OnFirstUseStrategy: + // for internal replica accounts, the platform filter must match that of the primary account, + // either by specifying the same filter explicitly or omitting it + // + // NOTE: This validates UpstreamPeerHostName as a side effect. + upstreamPlatformFilter, err := a.processor().GetPlatformFilterFromPrimaryAccount(r.Context(), targetAccount) + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf(`unknown peer registry: %q`, targetAccount.UpstreamPeerHostName) + http.Error(w, msg, http.StatusUnprocessableEntity) + return + } + if respondwith.ErrorText(w, err) { + return } - } - } - - // replication strategy may not be changed after account creation - if account != nil && req.Account.ReplicationPolicy != nil && !replicationPoliciesFunctionallyEqual(req.Account.ReplicationPolicy, renderReplicationPolicy(*account)) { - http.Error(w, `cannot change replication policy on existing account`, http.StatusConflict) - return - } - if account != nil && req.Account.PlatformFilter != nil && !reflect.DeepEqual(req.Account.PlatformFilter, account.PlatformFilter) { - http.Error(w, `cannot change platform filter on existing account`, http.StatusConflict) - return - } - // late RBAC policy validations (could not do these earlier because we did not - // have `account` yet) - isExternalReplica := req.Account.ReplicationPolicy != nil && req.Account.ReplicationPolicy.ExternalPeer.URL != "" - if account != nil { - isExternalReplica = account.ExternalPeerURL != "" - } - for _, policy := range req.Account.RBACPolicies { - if slices.Contains(policy.Permissions, keppel.GrantsAnonymousFirstPull) && !isExternalReplica { - http.Error(w, `RBAC policy with "anonymous_first_pull" may only be for external replica accounts`, http.StatusUnprocessableEntity) - return + if req.Account.PlatformFilter != nil && !upstreamPlatformFilter.IsEqualTo(req.Account.PlatformFilter) { + jsonPlatformFilter, _ := json.Marshal(req.Account.PlatformFilter) + jsonFilter, _ := json.Marshal(upstreamPlatformFilter) + msg := fmt.Sprintf( + "peer account filter needs to match primary account filter: local account %s, peer account %s ", + jsonPlatformFilter, jsonFilter) + http.Error(w, msg, http.StatusConflict) + return + } + targetAccount.PlatformFilter = upstreamPlatformFilter } } // create account if required - if account == nil { + if originalAccount == nil { // sublease tokens are only relevant when creating replica accounts subleaseTokenSecret := "" - if accountToCreate.UpstreamPeerHostName != "" { + if targetAccount.UpstreamPeerHostName != "" { subleaseToken, err := SubleaseTokenFromRequest(r) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -383,7 +379,7 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { // check permission to claim account name (this only happens here because // it's only relevant for account creations, not for updates) - claimResult, err := a.fd.ClaimAccountName(r.Context(), accountToCreate, subleaseTokenSecret) + claimResult, err := a.fd.ClaimAccountName(r.Context(), targetAccount, subleaseTokenSecret) switch claimResult { case keppel.ClaimSucceeded: // nothing to do @@ -397,50 +393,7 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { return } - // Copy PlatformFilter when creating an account with the Replication Policy on_first_use - if req.Account.ReplicationPolicy != nil { - rp := *req.Account.ReplicationPolicy - if rp.Strategy == "on_first_use" { - var peer models.Peer - err := a.db.SelectOne(&peer, `SELECT * FROM peers WHERE hostname = $1`, rp.UpstreamPeerHostName) - if errors.Is(err, sql.ErrNoRows) { - http.Error(w, fmt.Sprintf(`unknown peer registry: %q`, rp.UpstreamPeerHostName), http.StatusUnprocessableEntity) - return - } - if respondwith.ErrorText(w, err) { - return - } - - viewScope := auth.Scope{ - ResourceType: "keppel_account", - ResourceName: accountToCreate.Name, - Actions: []string{"view"}, - } - client, err := peerclient.New(r.Context(), a.cfg, peer, viewScope) - if respondwith.ErrorText(w, err) { - return - } - - var upstreamAccount Account - err = client.GetForeignAccountConfigurationInto(r.Context(), &upstreamAccount, accountToCreate.Name) - if respondwith.ErrorText(w, err) { - return - } - - if req.Account.PlatformFilter == nil { - accountToCreate.PlatformFilter = upstreamAccount.PlatformFilter - } else if !reflect.DeepEqual(req.Account.PlatformFilter, upstreamAccount.PlatformFilter) { - // check if the peer PlatformFilter matches the primary account PlatformFilter - jsonPlatformFilter, _ := json.Marshal(req.Account.PlatformFilter) - jsonFilter, _ := json.Marshal(upstreamAccount.PlatformFilter) - msg := fmt.Sprintf("peer account filter needs to match primary account filter: primary account %s, peer account %s ", jsonPlatformFilter, jsonFilter) - http.Error(w, msg, http.StatusConflict) - return - } - } - } - - err = a.sd.CanSetupAccount(accountToCreate) + err = a.sd.CanSetupAccount(targetAccount) if err != nil { msg := "cannot set up backing storage for this account: " + err.Error() http.Error(w, msg, http.StatusConflict) @@ -453,8 +406,7 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { } defer sqlext.RollbackUnlessCommitted(tx) - account = &accountToCreate - err = tx.Insert(account) + err = tx.Insert(&targetAccount) if respondwith.ErrorText(w, err) { return } @@ -471,92 +423,41 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { User: userInfo, ReasonCode: http.StatusOK, Action: cadf.CreateAction, - Target: AuditAccount{Account: *account}, + Target: AuditAccount{Account: targetAccount}, }) } } else { - // account != nil: update if necessary - needsUpdate := false - needsAudit := false - if account.InMaintenance != accountToCreate.InMaintenance { - account.InMaintenance = accountToCreate.InMaintenance - needsUpdate = true - } - if account.MetadataJSON != accountToCreate.MetadataJSON { - account.MetadataJSON = accountToCreate.MetadataJSON - needsUpdate = true - } - if account.GCPoliciesJSON != accountToCreate.GCPoliciesJSON { - account.GCPoliciesJSON = accountToCreate.GCPoliciesJSON - needsUpdate = true - needsAudit = true - } - if account.RBACPoliciesJSON != accountToCreate.RBACPoliciesJSON { - account.RBACPoliciesJSON = accountToCreate.RBACPoliciesJSON - needsUpdate = true - needsAudit = true - } - if account.RequiredLabels != accountToCreate.RequiredLabels { - account.RequiredLabels = accountToCreate.RequiredLabels - needsUpdate = true - } - if account.ExternalPeerUserName != accountToCreate.ExternalPeerUserName { - account.ExternalPeerUserName = accountToCreate.ExternalPeerUserName - needsUpdate = true - } - if account.ExternalPeerPassword != accountToCreate.ExternalPeerPassword { - account.ExternalPeerPassword = accountToCreate.ExternalPeerPassword - needsUpdate = true - } - if needsUpdate { - _, err := a.db.Update(account) + // originalAccount != nil: update if necessary + if !reflect.DeepEqual(*originalAccount, targetAccount) { + _, err := a.db.Update(&targetAccount) if respondwith.ErrorText(w, err) { return } } - if needsAudit { - if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { + + // audit log is necessary for all changes except to InMaintenance + if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { + originalAccount.InMaintenance = targetAccount.InMaintenance + if !reflect.DeepEqual(*originalAccount, targetAccount) { a.auditor.Record(audittools.EventParameters{ Time: time.Now(), Request: r, User: userInfo, ReasonCode: http.StatusOK, Action: cadf.UpdateAction, - Target: AuditAccount{Account: *account}, + Target: AuditAccount{Account: targetAccount}, }) } } } - accountRendered, err := a.renderAccount(*account) + accountRendered, err := a.renderAccount(targetAccount) if respondwith.ErrorText(w, err) { return } respondwith.JSON(w, http.StatusOK, map[string]any{"account": accountRendered}) } -// Like reflect.DeepEqual, but ignores some fields that are allowed to be -// updated after account creation. -func replicationPoliciesFunctionallyEqual(lhs, rhs *keppel.ReplicationPolicy) bool { - // one nil and one non-nil is not equal - if (lhs == nil) != (rhs == nil) { - return false - } - // two nil's are equal - if lhs == nil { - return true - } - - // ignore pull credentials (the user shall be able to change these after account creation) - lhsClone := *lhs - rhsClone := *rhs - lhsClone.ExternalPeer.UserName = "" - lhsClone.ExternalPeer.Password = "" - rhsClone.ExternalPeer.UserName = "" - rhsClone.ExternalPeer.Password = "" - return reflect.DeepEqual(lhsClone, rhsClone) -} - type deleteAccountRemainingManifest struct { RepositoryName string `json:"repository"` Digest string `json:"digest"` diff --git a/internal/api/keppel/accounts_test.go b/internal/api/keppel/accounts_test.go index f5b758c7..a330b7e9 100644 --- a/internal/api/keppel/accounts_test.go +++ b/internal/api/keppel/accounts_test.go @@ -1176,6 +1176,24 @@ func TestPutAccountErrorCases(t *testing.T) { }}, }, }, + ExpectStatus: http.StatusConflict, + ExpectBody: assert.StringData("cannot change platform filter on existing account\n"), + }.Check(t, h) + + // test unexpected platform filter on new primary account + assert.HTTPRequest{ + Method: "PUT", + Path: "/keppel/v1/accounts/third", + Header: map[string]string{"X-Test-Perms": "change:tenant1"}, + Body: assert.JSONObject{ + "account": assert.JSONObject{ + "auth_tenant_id": "tenant1", + "platform_filter": []assert.JSONObject{{ + "os": "linux", + "architecture": "amd64", + }}, + }, + }, ExpectStatus: http.StatusUnprocessableEntity, ExpectBody: assert.StringData("platform filter is only allowed on replica accounts\n"), }.Check(t, h) @@ -2222,7 +2240,7 @@ func TestReplicaAccountsInheritPlatformFilter(t *testing.T) { }, }, ExpectStatus: http.StatusConflict, - ExpectBody: assert.StringData("peer account filter needs to match primary account filter: primary account [{\"architecture\":\"arm64\",\"os\":\"linux\",\"variant\":\"v8\"}], peer account [{\"architecture\":\"amd64\",\"os\":\"linux\"}] \n"), + ExpectBody: assert.StringData("peer account filter needs to match primary account filter: local account [{\"architecture\":\"arm64\",\"os\":\"linux\",\"variant\":\"v8\"}], peer account [{\"architecture\":\"amd64\",\"os\":\"linux\"}] \n"), }.Check(t, s2.Handler) }) } diff --git a/internal/keppel/rbac_policy.go b/internal/keppel/rbac_policy.go index 67cd9c47..3d6a3354 100644 --- a/internal/keppel/rbac_policy.go +++ b/internal/keppel/rbac_policy.go @@ -80,7 +80,7 @@ func (r RBACPolicy) Matches(ip, repoName, userName string) bool { // ValidateAndNormalize performs some normalizations and returns an error if // this policy is invalid. -func (r *RBACPolicy) ValidateAndNormalize() error { +func (r *RBACPolicy) ValidateAndNormalize(strategy ReplicationStrategy) error { if r.CidrPattern != "" { _, network, err := net.ParseCIDR(r.CidrPattern) if err != nil { @@ -119,6 +119,9 @@ func (r *RBACPolicy) ValidateAndNormalize() error { if hasPerm[GrantsDelete] && r.UserNamePattern == "" { return errors.New(`RBAC policy with "delete" must have the "match_username" attribute`) } + if hasPerm[GrantsAnonymousFirstPull] && strategy != FromExternalOnFirstUseStrategy { + return errors.New(`RBAC policy with "anonymous_first_pull" may only be for external replica accounts`) + } return nil } diff --git a/internal/keppel/replication.go b/internal/keppel/replication.go index 148bca0a..e0221fec 100644 --- a/internal/keppel/replication.go +++ b/internal/keppel/replication.go @@ -22,20 +22,28 @@ import ( "encoding/json" "errors" "fmt" - "net/http" "github.com/sapcc/keppel/internal/models" ) // ReplicationPolicy represents a replication policy in the API. type ReplicationPolicy struct { - Strategy string `json:"strategy"` + Strategy ReplicationStrategy `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"` } +// ReplicationStrategy is an enum that appears in type ReplicationPolicy. +type ReplicationStrategy string + +const ( + NoReplicationStrategy ReplicationStrategy = "" + OnFirstUseStrategy ReplicationStrategy = "on_first_use" + FromExternalOnFirstUseStrategy ReplicationStrategy = "from_external_on_first_use" +) + // ReplicationExternalPeerSpec appears in type ReplicationPolicy. type ReplicationExternalPeerSpec struct { URL string `json:"url"` @@ -46,15 +54,15 @@ type ReplicationExternalPeerSpec struct { // MarshalJSON implements the json.Marshaler interface. func (r ReplicationPolicy) MarshalJSON() ([]byte, error) { switch r.Strategy { - case "on_first_use": + case OnFirstUseStrategy: data := struct { - Strategy string `json:"strategy"` - UpstreamPeerHostName string `json:"upstream"` + Strategy ReplicationStrategy `json:"strategy"` + UpstreamPeerHostName string `json:"upstream"` }{r.Strategy, r.UpstreamPeerHostName} return json.Marshal(data) - case "from_external_on_first_use": + case FromExternalOnFirstUseStrategy: data := struct { - Strategy string `json:"strategy"` + Strategy ReplicationStrategy `json:"strategy"` ExternalPeer ReplicationExternalPeerSpec `json:"upstream"` }{r.Strategy, r.ExternalPeer} return json.Marshal(data) @@ -66,8 +74,8 @@ func (r ReplicationPolicy) MarshalJSON() ([]byte, error) { // UnmarshalJSON implements the json.Unmarshaler interface. func (r *ReplicationPolicy) UnmarshalJSON(buf []byte) error { var s struct { - Strategy string `json:"strategy"` - Upstream json.RawMessage `json:"upstream"` + Strategy ReplicationStrategy `json:"strategy"` + Upstream json.RawMessage `json:"upstream"` } err := json.Unmarshal(buf, &s) if err != nil { @@ -76,41 +84,103 @@ func (r *ReplicationPolicy) UnmarshalJSON(buf []byte) error { r.Strategy = s.Strategy switch r.Strategy { - case "on_first_use": + case OnFirstUseStrategy: return json.Unmarshal(s.Upstream, &r.UpstreamPeerHostName) - case "from_external_on_first_use": + case FromExternalOnFirstUseStrategy: return json.Unmarshal(s.Upstream, &r.ExternalPeer) default: return fmt.Errorf("do not know how to deserialize ReplicationPolicy with strategy %q", r.Strategy) } } +// RenderReplicationPolicy builds a ReplicationPolicy object out of the +// information in the given account model. +func RenderReplicationPolicy(account models.Account) *ReplicationPolicy { + if account.UpstreamPeerHostName != "" { + return &ReplicationPolicy{ + Strategy: OnFirstUseStrategy, + UpstreamPeerHostName: account.UpstreamPeerHostName, + } + } + + if account.ExternalPeerURL != "" { + return &ReplicationPolicy{ + Strategy: FromExternalOnFirstUseStrategy, + ExternalPeer: ReplicationExternalPeerSpec{ + URL: account.ExternalPeerURL, + UserName: account.ExternalPeerUserName, + //NOTE: Password is omitted here for security reasons + }, + } + } + + return nil +} + +var ( + ErrIncompatibleReplicationPolicy = errors.New("cannot change replication policy on existing account") +) + // ApplyToAccount validates this policy and stores it in the given account model. -func (r ReplicationPolicy) ApplyToAccount(db *DB, dbAccount *models.Account) *RegistryV2Error { +// +// WARNING 1: For existing accounts, the caller must ensure that the policy uses +// the same replication strategy as the given account already does. +// +// WARNING 2: For internal replica accounts, the caller must ensure that the +// UpstreamPeerHostName refers to a known peer. This method does not do it +// itself because callers often need to do other things with the peer, too. +func (r ReplicationPolicy) ApplyToAccount(account *models.Account) error { switch r.Strategy { - case "on_first_use": - peerCount, err := db.SelectInt(`SELECT COUNT(*) FROM peers WHERE hostname = $1`, r.UpstreamPeerHostName) - if err != nil { - return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + case OnFirstUseStrategy: + if account.UpstreamPeerHostName == "" { + // on new accounts, accept any upstream peer + account.UpstreamPeerHostName = r.UpstreamPeerHostName + } else if account.UpstreamPeerHostName != r.UpstreamPeerHostName { + // on existing accounts, changing the upstream peer is not allowed + return ErrIncompatibleReplicationPolicy } - if peerCount == 0 { - err := fmt.Errorf(`unknown peer registry: %q`, r.UpstreamPeerHostName) - return AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) - } - dbAccount.UpstreamPeerHostName = r.UpstreamPeerHostName - case "from_external_on_first_use": - if r.ExternalPeer.URL == "" { - err := errors.New(`missing upstream URL for "from_external_on_first_use" replication`) - return AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + case FromExternalOnFirstUseStrategy: + rerr := r.ExternalPeer.applyToAccount(account) + if rerr != nil { + return rerr } - dbAccount.ExternalPeerURL = r.ExternalPeer.URL - dbAccount.ExternalPeerUserName = r.ExternalPeer.UserName - dbAccount.ExternalPeerPassword = r.ExternalPeer.Password + default: - err := fmt.Errorf("strategy %s is unsupported", r.Strategy) - return AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + return fmt.Errorf("strategy %s is unsupported", r.Strategy) } return nil } + +func (r ReplicationExternalPeerSpec) applyToAccount(account *models.Account) error { + // peer URL must be given for new accounts, and stay consistent for existing accounts + if r.URL == "" { + return errors.New(`missing upstream URL for "from_external_on_first_use" replication`) + } + isNewAccount := account.ExternalPeerURL == "" + if isNewAccount { + account.ExternalPeerURL = r.URL + } else if account.ExternalPeerURL != r.URL { + return ErrIncompatibleReplicationPolicy + } + + // on existing accounts, having only a username is acceptable if it's unchanged + // (this case occurs when a client GETs the account, changes something not related + // to replication, and PUTs the result; the password is redacted in GET) + if !isNewAccount && r.UserName != "" && r.Password == "" { + if r.UserName == account.ExternalPeerUserName { + r.Password = account.ExternalPeerPassword // to save it from being overwritten below + } else { + return errors.New(`cannot change username for "from_external_on_first_use" replication without also changing password`) + } + } + + // pull credentials can be updated mostly at will + if (r.UserName == "") != (r.Password == "") { + return errors.New(`need either both username and password or neither for "from_external_on_first_use" replication`) + } + account.ExternalPeerUserName = r.UserName + account.ExternalPeerPassword = r.Password + return nil +} diff --git a/internal/models/platform_filter.go b/internal/models/platform_filter.go index 11a023ef..41a5d308 100644 --- a/internal/models/platform_filter.go +++ b/internal/models/platform_filter.go @@ -83,3 +83,17 @@ func (f PlatformFilter) Includes(platform manifestlist.PlatformSpec) bool { } return false } + +// IsEqualTo checks whether both filters are equal. +func (f PlatformFilter) IsEqualTo(other PlatformFilter) bool { + if len(f) != len(other) { + return false + } + + for idx, p := range f { + if !reflect.DeepEqual(p, other[idx]) { + return false + } + } + return true +} diff --git a/internal/processor/accounts.go b/internal/processor/accounts.go new file mode 100644 index 000000000..1ad3bc9a --- /dev/null +++ b/internal/processor/accounts.go @@ -0,0 +1,69 @@ +/******************************************************************************* +* +* 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 should have received a copy of the License along with this +* program. If not, 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 processor + +import ( + "context" + + "github.com/sapcc/keppel/internal/auth" + peerclient "github.com/sapcc/keppel/internal/client/peer" + "github.com/sapcc/keppel/internal/keppel" + "github.com/sapcc/keppel/internal/models" +) + +// GetPlatformFilterFromPrimaryAccount takes a replica account and queries the +// peer holding the primary account for that account's platform filter. +// +// Returns sql.ErrNoRows if the configured peer does not exist. +func (p *Processor) GetPlatformFilterFromPrimaryAccount(ctx context.Context, replicaAccount models.Account) (models.PlatformFilter, error) { + var peer models.Peer + err := p.db.SelectOne(&peer, `SELECT * FROM peers WHERE hostname = $1`, replicaAccount.UpstreamPeerHostName) + if err != nil { + return nil, err + } + + viewScope := auth.Scope{ + ResourceType: "keppel_account", + ResourceName: replicaAccount.Name, + Actions: []string{"view"}, + } + client, err := peerclient.New(ctx, p.cfg, peer, viewScope) + if err != nil { + return nil, err + } + + //TODO: use type keppelv1.Account once it is moved to package keppel + var upstreamAccount struct { + Name string `json:"name"` + AuthTenantID string `json:"auth_tenant_id"` + GCPolicies []keppel.GCPolicy `json:"gc_policies,omitempty"` + InMaintenance bool `json:"in_maintenance"` + Metadata map[string]string `json:"metadata"` + RBACPolicies []keppel.RBACPolicy `json:"rbac_policies"` + ReplicationPolicy *keppel.ReplicationPolicy `json:"replication,omitempty"` + ValidationPolicy *keppel.ValidationPolicy `json:"validation,omitempty"` + PlatformFilter models.PlatformFilter `json:"platform_filter,omitempty"` + } + err = client.GetForeignAccountConfigurationInto(ctx, &upstreamAccount, replicaAccount.Name) + if err != nil { + return nil, err + } + return upstreamAccount.PlatformFilter, nil +}