From c0af43ec30b0c76f87bead2b25b8309a892d710d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sandro=20J=C3=A4ckel?= Date: Mon, 25 Mar 2024 17:45:01 +0100 Subject: [PATCH] Extra keppel account logic from API --- internal/api/keppel/accounts.go | 422 ++++----------------------- internal/api/keppel/accounts_test.go | 4 +- internal/keppel/account.go | 358 +++++++++++++++++++++++ 3 files changed, 418 insertions(+), 366 deletions(-) create mode 100644 internal/keppel/account.go diff --git a/internal/api/keppel/accounts.go b/internal/api/keppel/accounts.go index 28d08841..b1eff768 100644 --- a/internal/api/keppel/accounts.go +++ b/internal/api/keppel/accounts.go @@ -25,10 +25,7 @@ import ( "errors" "fmt" "net/http" - "reflect" - "regexp" "slices" - "strings" "time" "github.com/gorilla/mux" @@ -45,33 +42,14 @@ import ( "github.com/sapcc/keppel/internal/models" ) -//////////////////////////////////////////////////////////////////////////////// -// data types - -// Account represents an account in the API. -type Account 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"` -} - -//////////////////////////////////////////////////////////////////////////////// -// data conversion/validation functions - -func (a *API) renderAccount(dbAccount models.Account) (Account, error) { +func (a *API) renderAccount(dbAccount models.Account) (keppel.Account, error) { gcPolicies, err := keppel.ParseGCPolicies(dbAccount) if err != nil { - return Account{}, err + return keppel.Account{}, err } rbacPolicies, err := keppel.ParseRBACPolicies(dbAccount) if err != nil { - return Account{}, err + return keppel.Account{}, err } if rbacPolicies == nil { // do not render "null" in this field @@ -82,45 +60,23 @@ func (a *API) renderAccount(dbAccount models.Account) (Account, error) { if dbAccount.MetadataJSON != "" { err := json.Unmarshal([]byte(dbAccount.MetadataJSON), &metadata) if err != nil { - return Account{}, fmt.Errorf("malformed metadata JSON: %q", dbAccount.MetadataJSON) + return keppel.Account{}, fmt.Errorf("malformed metadata JSON: %q", dbAccount.MetadataJSON) } } - return Account{ + return keppel.Account{ Name: dbAccount.Name, AuthTenantID: dbAccount.AuthTenantID, GCPolicies: gcPolicies, 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 @@ -155,7 +111,7 @@ func (a *API) handleGetAccounts(w http.ResponseWriter, r *http.Request) { } // render accounts to JSON - accountsRendered := make([]Account, len(accountsFiltered)) + accountsRendered := make([]keppel.Account, len(accountsFiltered)) for idx, account := range accountsFiltered { accountsRendered[idx], err = a.renderAccount(account) if respondwith.ErrorText(w, err) { @@ -183,13 +139,11 @@ func (a *API) handleGetAccount(w http.ResponseWriter, r *http.Request) { respondwith.JSON(w, http.StatusOK, map[string]any{"account": accountRendered}) } -var looksLikeAPIVersionRx = regexp.MustCompile(`^v[0-9][1-9]*$`) - func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { httpapi.IdentifyEndpoint(r, "/keppel/v1/accounts/:account") // decode request body var req struct { - Account Account `json:"account"` + Account keppel.Account `json:"account"` } decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() @@ -206,262 +160,76 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { // ... transfer it here into the struct, to make the below code simpler req.Account.Name = mux.Vars(r)["account"] - if err := a.authDriver.ValidateTenantID(req.Account.AuthTenantID); err != nil { - http.Error(w, `malformed attribute "account.auth_tenant_id" in request body: `+err.Error(), http.StatusUnprocessableEntity) - return - } - - // reserve identifiers for internal pseudo-accounts and anything that might - // appear like the first path element of a legal endpoint path on any of our - // APIs (we will soon start recognizing image-like URLs such as - // keppel.example.org/account/repo and offer redirection to a suitable UI; - // this requires the account name to not overlap with API endpoint paths) - if strings.HasPrefix(req.Account.Name, "keppel") { - http.Error(w, `account names with the prefix "keppel" are reserved for internal use`, http.StatusUnprocessableEntity) - return - } - if looksLikeAPIVersionRx.MatchString(req.Account.Name) { - http.Error(w, `account names that look like API versions are reserved for internal use`, http.StatusUnprocessableEntity) + var ( + account *models.Account + authz *auth.Authorization + rerr *keppel.RegistryV2Error + ) + var needsCreation, needsUpdate, needsAudit bool + account, needsCreation, needsUpdate, needsAudit, rerr = req.Account.ValidateAndNormalize(a.authDriver, a.db, func(account models.Account) (bool, *keppel.RegistryV2Error) { + // check permission to create account + authz, rerr = a.authenticateRequest(r, authTenantScope(keppel.CanChangeAccount, account.AuthTenantID)) + return authz != nil, rerr + }) + if rerr != nil { + rerr.WriteAsTextTo(w) return } - for _, policy := range req.Account.GCPolicies { - err := policy.Validate() - if err != nil { - http.Error(w, err.Error(), http.StatusUnprocessableEntity) - 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 - } - - metadataJSONStr := "" - if len(req.Account.Metadata) > 0 { - metadataJSON, _ := json.Marshal(req.Account.Metadata) - metadataJSONStr = string(metadataJSON) - } - - gcPoliciesJSONStr := "[]" - if len(req.Account.GCPolicies) > 0 { - gcPoliciesJSON, _ := json.Marshal(req.Account.GCPolicies) - gcPoliciesJSONStr = string(gcPoliciesJSON) - } - - rbacPoliciesJSONStr := "" - if len(req.Account.RBACPolicies) > 0 { - rbacPoliciesJSON, _ := json.Marshal(req.Account.RBACPolicies) - rbacPoliciesJSONStr = string(rbacPoliciesJSON) - } - - 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 - if req.Account.ReplicationPolicy != nil { - rp := *req.Account.ReplicationPolicy - - rerr := rp.ApplyToAccount(a.db, &accountToCreate) - if rerr != nil { - rerr.WriteAsTextTo(w) - return - } - //NOTE: There are some delayed checks below which require the existing account to be loaded from the DB first. - } - - // validate validation policy - if req.Account.ValidationPolicy != nil { - rerr := req.Account.ValidationPolicy.ApplyToAccount(&accountToCreate) - if rerr != nil { - rerr.WriteAsTextTo(w) + if needsUpdate { + _, err := a.db.Update(account) + if respondwith.ErrorText(w, err) { return } } - - // 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) - return + if needsAudit { + if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { + a.auditor.Record(audittools.EventParameters{ + Time: time.Now(), + Request: r, + User: userInfo, + ReasonCode: http.StatusOK, + Action: cadf.UpdateAction, + Target: AuditAccount{Account: *account}, + }) } - accountToCreate.PlatformFilter = req.Account.PlatformFilter - } - - // check permission to create account - authz := a.authenticateRequestAndWriteError(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 - } + // create account if required + if needsCreation { + // TODO: how to best avoid the circular import? + getUpstreamAccount := func(upstreamPeerHostName string) (upstreamAccount keppel.Account) { + var peer models.Peer + err := a.db.SelectOne(&peer, `SELECT * FROM peers WHERE hostname = $1`, upstreamPeerHostName) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, fmt.Sprintf(`unknown peer registry: %q`, upstreamPeerHostName), 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 - } + 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 - } - } - // create account if required - if account == nil { - // sublease tokens are only relevant when creating replica accounts - subleaseTokenSecret := "" - if accountToCreate.UpstreamPeerHostName != "" { - subleaseToken, err := keppel.SubleaseTokenFromRequest(r.Header.Get(keppel.SubleaseHeader)) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) + viewScope := auth.Scope{ + ResourceType: "keppel_account", + ResourceName: account.Name, + Actions: []string{"view"}, + } + client, err := peerclient.New(r.Context(), a.cfg, peer, viewScope) + if respondwith.ErrorText(w, err) { return } - subleaseTokenSecret = subleaseToken.Secret - } - - // 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) - switch claimResult { - case keppel.ClaimSucceeded: - // nothing to do - case keppel.ClaimFailed: - // user error - http.Error(w, err.Error(), http.StatusForbidden) - return - case keppel.ClaimErrored: - // server error - http.Error(w, err.Error(), http.StatusInternalServerError) - 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 = client.GetForeignAccountConfigurationInto(r.Context(), &upstreamAccount, account.Name) + if respondwith.ErrorText(w, err) { + return } - } - - err = a.sd.CanSetupAccount(accountToCreate) - if err != nil { - msg := "cannot set up backing storage for this account: " + err.Error() - http.Error(w, msg, http.StatusConflict) - return - } - - tx, err := a.db.Begin() - if respondwith.ErrorText(w, err) { - return - } - defer sqlext.RollbackUnlessCommitted(tx) - account = &accountToCreate - err = tx.Insert(account) - if respondwith.ErrorText(w, err) { return } - // commit the changes - err = tx.Commit() - if respondwith.ErrorText(w, err) { + rerr = keppel.CreateAccountInDB(r.Context(), a.db, a.fd, a.sd, r.Header.Get(keppel.SubleaseHeader), getUpstreamAccount, account) + if rerr != nil { + rerr.WriteAsTextTo(w) return } if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { @@ -474,58 +242,6 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { Target: AuditAccount{Account: *account}, }) } - } 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) - if respondwith.ErrorText(w, err) { - return - } - } - if needsAudit { - if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { - a.auditor.Record(audittools.EventParameters{ - Time: time.Now(), - Request: r, - User: userInfo, - ReasonCode: http.StatusOK, - Action: cadf.UpdateAction, - Target: AuditAccount{Account: *account}, - }) - } - } } accountRendered, err := a.renderAccount(*account) @@ -535,28 +251,6 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { 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 13a7fac8..3e776655 100644 --- a/internal/api/keppel/accounts_test.go +++ b/internal/api/keppel/accounts_test.go @@ -593,7 +593,7 @@ func TestPutAccountErrorCases(t *testing.T) { }, }, ExpectStatus: http.StatusUnprocessableEntity, - ExpectBody: assert.StringData("malformed attribute \"account.auth_tenant_id\" in request body: must not be \"invalid\"\n"), + ExpectBody: assert.StringData("malformed attribute \"auth_tenant_id\": must not be \"invalid\"\n"), }.Check(t, h) assert.HTTPRequest{ @@ -619,7 +619,7 @@ func TestPutAccountErrorCases(t *testing.T) { }, }, ExpectStatus: http.StatusUnprocessableEntity, - ExpectBody: assert.StringData("account names that look like API versions are reserved for internal use\n"), + ExpectBody: assert.StringData("account names that look like API versions (eg. v1) are reserved for internal use\n"), }.Check(t, h) assert.HTTPRequest{ diff --git a/internal/keppel/account.go b/internal/keppel/account.go new file mode 100644 index 000000000..1a682c1b --- /dev/null +++ b/internal/keppel/account.go @@ -0,0 +1,358 @@ +/****************************************************************************** +* +* 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 ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "regexp" + "slices" + "strings" + + "github.com/sapcc/keppel/internal/models" + + "github.com/sapcc/go-bits/sqlext" +) + +// Account represents an account in the API. +type Account struct { + Name string `json:"name"` + AuthTenantID string `json:"auth_tenant_id"` + GCPolicies []GCPolicy `json:"gc_policies,omitempty"` + InMaintenance bool `json:"in_maintenance"` + Metadata map[string]string `json:"metadata"` + RBACPolicies []RBACPolicy `json:"rbac_policies"` + ReplicationPolicy *ReplicationPolicy `json:"replication,omitempty"` + ValidationPolicy *ValidationPolicy `json:"validation,omitempty"` + PlatformFilter models.PlatformFilter `json:"platform_filter,omitempty"` +} + +var looksLikeAPIVersionRx = regexp.MustCompile(`^v[0-9][1-9]*$`) + +// Like reflect.DeepEqual, but ignores some fields that are allowed to be +// updated after account creation. +func replicationPoliciesFunctionallyEqual(lhs, rhs *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) +} + +func RenderReplicationPolicy(dbAccount models.Account) *ReplicationPolicy { + if dbAccount.UpstreamPeerHostName != "" { + return &ReplicationPolicy{ + Strategy: "on_first_use", + UpstreamPeerHostName: dbAccount.UpstreamPeerHostName, + } + } + + if dbAccount.ExternalPeerURL != "" { + return &ReplicationPolicy{ + Strategy: "from_external_on_first_use", + ExternalPeer: ReplicationExternalPeerSpec{ + URL: dbAccount.ExternalPeerURL, + UserName: dbAccount.ExternalPeerUserName, + //NOTE: Password is omitted here for security reasons + }, + } + } + + return nil +} + +// ValidateAndNormalize can be used on an API account and returns the database representation of it. +func (a *Account) ValidateAndNormalize(authDriver AuthDriver, db *DB, isAuthenticated func(models.Account) (bool, *RegistryV2Error)) (account *models.Account, needsCreation, needsUpdate, needsAudit bool, rerr *RegistryV2Error) { + err := authDriver.ValidateTenantID(a.AuthTenantID) + if err != nil { + return nil, false, false, false, AsRegistryV2Error(fmt.Errorf(`malformed attribute "auth_tenant_id": %w`, err)).WithStatus(http.StatusUnprocessableEntity) + } + + // reserve identifiers for internal pseudo-accounts and anything that might + // appear like the first path element of a legal endpoint path on any of our + // APIs (we will soon start recognizing image-like URLs such as + // keppel.example.org/account/repo and offer redirection to a suitable UI; + // this requires the account name to not overlap with API endpoint paths) + if strings.HasPrefix(a.Name, "keppel") { + return nil, false, false, false, AsRegistryV2Error(errors.New(`account names with the prefix "keppel" are reserved for internal use`)).WithStatus(http.StatusUnprocessableEntity) + } + if looksLikeAPIVersionRx.MatchString(a.Name) { + return nil, false, false, false, AsRegistryV2Error(errors.New(`account names that look like API versions (eg. v1) are reserved for internal use`)).WithStatus(http.StatusUnprocessableEntity) + } + + for _, policy := range a.GCPolicies { + err := policy.Validate() + if err != nil { + return nil, false, false, false, AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + } + } + + for idx, policy := range a.RBACPolicies { + err := policy.ValidateAndNormalize() + if err != nil { + return nil, false, false, false, AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + } + a.RBACPolicies[idx] = policy + } + + metadataJSONStr := "" + if len(a.Metadata) > 0 { + metadataJSON, _ := json.Marshal(a.Metadata) + metadataJSONStr = string(metadataJSON) + } + + gcPoliciesJSONStr := "[]" + if len(a.GCPolicies) > 0 { + gcPoliciesJSON, _ := json.Marshal(a.GCPolicies) + gcPoliciesJSONStr = string(gcPoliciesJSON) + } + + rbacPoliciesJSONStr := "" + if len(a.RBACPolicies) > 0 { + rbacPoliciesJSON, _ := json.Marshal(a.RBACPolicies) + rbacPoliciesJSONStr = string(rbacPoliciesJSON) + } + + accountToCreate := models.Account{ + Name: a.Name, + AuthTenantID: a.AuthTenantID, + InMaintenance: a.InMaintenance, + MetadataJSON: metadataJSONStr, + GCPoliciesJSON: gcPoliciesJSONStr, + RBACPoliciesJSON: rbacPoliciesJSONStr, + SecurityScanPoliciesJSON: "[]", + } + + // db MUST NOT be used before this point, otherwise we could leak internals without authentication! + if authed, rerr := isAuthenticated(accountToCreate); !authed { + return nil, false, false, false, rerr + } + + // validate replication policy + if a.ReplicationPolicy != nil { + rp := *a.ReplicationPolicy + + err := rp.ApplyToAccount(db, &accountToCreate) + if err != nil { + return nil, false, false, false, err + } + //NOTE: There are some delayed checks below which require the existing account to be loaded from the DB first. + } + + // validate validation policy + if a.ValidationPolicy != nil { + err := a.ValidationPolicy.ApplyToAccount(&accountToCreate) + if err != nil { + return nil, false, false, false, err + } + } + + // validate platform filter + if a.PlatformFilter != nil { + if a.ReplicationPolicy == nil { + return nil, false, false, false, AsRegistryV2Error(errors.New(`platform filter is only allowed on replica accounts`)).WithStatus(http.StatusUnprocessableEntity) + } + accountToCreate.PlatformFilter = a.PlatformFilter + } + + // check if accountToUpdate already exists + accountToUpdate, err := FindAccount(db, a.Name) + if err != nil { + return nil, false, false, false, AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + if accountToUpdate != nil { + if accountToUpdate.AuthTenantID != a.AuthTenantID { + return nil, false, false, false, AsRegistryV2Error(errors.New(`account name already in use by a different tenant`)).WithStatus(http.StatusConflict) + } + } + + // late replication policy validations (could not do these earlier because we + // did not have `account` yet) + if a.ReplicationPolicy != nil { + rp := *a.ReplicationPolicy + + if rp.Strategy == "from_external_on_first_use" { + // for new accounts, we need either full credentials or none + if accountToUpdate == nil { + if (rp.ExternalPeer.UserName == "") != (rp.ExternalPeer.Password == "") { + return nil, false, false, false, AsRegistryV2Error(errors.New(`need either both username and password or neither for "from_external_on_first_use" replication`)).WithStatus(http.StatusUnprocessableEntity) + } + } + + // 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 accountToUpdate != nil && rp.ExternalPeer.UserName != "" && rp.ExternalPeer.Password == "" { + if rp.ExternalPeer.UserName == accountToUpdate.ExternalPeerUserName { + rp.ExternalPeer.Password = accountToUpdate.ExternalPeerPassword // to pass the equality checks below + } else { + return nil, false, false, false, AsRegistryV2Error(errors.New(`cannot change username for "from_external_on_first_use" replication without also changing password`)).WithStatus(http.StatusUnprocessableEntity) + } + } + } + } + + // replication strategy may not be changed after account creation + if accountToUpdate != nil { + if a.ReplicationPolicy != nil && !replicationPoliciesFunctionallyEqual(a.ReplicationPolicy, RenderReplicationPolicy(*accountToUpdate)) { + return nil, false, false, false, AsRegistryV2Error(errors.New(`cannot change replication policy on existing account`)).WithStatus(http.StatusConflict) + } + if a.PlatformFilter != nil && !reflect.DeepEqual(a.PlatformFilter, accountToUpdate.PlatformFilter) { + return nil, false, false, false, AsRegistryV2Error(errors.New(`cannot change platform filter on existing account`)).WithStatus(http.StatusConflict) + } + } + + // late RBAC policy validations (could not do these earlier because we did not + // have `account` yet) + isExternalReplica := a.ReplicationPolicy != nil && a.ReplicationPolicy.ExternalPeer.URL != "" + if accountToUpdate != nil { + isExternalReplica = accountToUpdate.ExternalPeerURL != "" + } + for _, policy := range a.RBACPolicies { + if slices.Contains(policy.Permissions, GrantsAnonymousFirstPull) && !isExternalReplica { + return nil, false, false, false, AsRegistryV2Error(errors.New(`RBAC policy with "anonymous_first_pull" may only be for external replica accounts`)).WithStatus(http.StatusUnprocessableEntity) + } + } + + // TODO: why not always create an audit event? + if accountToUpdate != nil { + if accountToUpdate.InMaintenance != accountToCreate.InMaintenance { + accountToUpdate.InMaintenance = accountToCreate.InMaintenance + needsUpdate = true + } + if accountToUpdate.MetadataJSON != accountToCreate.MetadataJSON { + accountToUpdate.MetadataJSON = accountToCreate.MetadataJSON + needsUpdate = true + } + if accountToUpdate.GCPoliciesJSON != accountToCreate.GCPoliciesJSON { + accountToUpdate.GCPoliciesJSON = accountToCreate.GCPoliciesJSON + needsUpdate = true + needsAudit = true + } + if accountToUpdate.RBACPoliciesJSON != accountToCreate.RBACPoliciesJSON { + accountToUpdate.RBACPoliciesJSON = accountToCreate.RBACPoliciesJSON + needsUpdate = true + needsAudit = true + } + if accountToUpdate.RequiredLabels != accountToCreate.RequiredLabels { + accountToUpdate.RequiredLabels = accountToCreate.RequiredLabels + needsUpdate = true + } + if accountToUpdate.ExternalPeerUserName != accountToCreate.ExternalPeerUserName { + accountToUpdate.ExternalPeerUserName = accountToCreate.ExternalPeerUserName + needsUpdate = true + } + if accountToUpdate.ExternalPeerPassword != accountToCreate.ExternalPeerPassword { + accountToUpdate.ExternalPeerPassword = accountToCreate.ExternalPeerPassword + needsUpdate = true + } + } + + if accountToUpdate == nil { + return &accountToCreate, true, false, false, nil + } + return accountToUpdate, false, needsUpdate, needsAudit, nil +} + +func CreateAccountInDB(ctx context.Context, db *DB, fd FederationDriver, sd StorageDriver, subleaseHeader string, getUpstreamAccount func(upstreamPeerHostName string) (upstreamAccount Account), account *models.Account) *RegistryV2Error { + // sublease tokens are only relevant when creating replica accounts + subleaseTokenSecret := "" + if account.UpstreamPeerHostName != "" { + subleaseToken, err := SubleaseTokenFromRequest(subleaseHeader) + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusBadRequest) + } + subleaseTokenSecret = subleaseToken.Secret + } + + // check permission to claim account name (this only happens here because + // it's only relevant for account creations, not for updates) + claimResult, err := fd.ClaimAccountName(ctx, *account, subleaseTokenSecret) + switch claimResult { + case ClaimSucceeded: + // nothing to do + case ClaimFailed: + // user error + return AsRegistryV2Error(err).WithStatus(http.StatusForbidden) + case ClaimErrored: + // server error + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + // Copy PlatformFilter when creating an account with the Replication Policy on_first_use + if account.UpstreamPeerHostName != "" { + upstreamAccount := getUpstreamAccount(account.UpstreamPeerHostName) + + if account.PlatformFilter == nil { + account.PlatformFilter = upstreamAccount.PlatformFilter + } else if !reflect.DeepEqual(account.PlatformFilter, upstreamAccount.PlatformFilter) { + // check if the peer PlatformFilter matches the primary account PlatformFilter + jsonPlatformFilter, err := json.Marshal(account.PlatformFilter) + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + jsonFilter, err := json.Marshal(upstreamAccount.PlatformFilter) + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + msg := fmt.Sprintf("peer account filter needs to match primary account filter: primary account %s, peer account %s ", jsonPlatformFilter, jsonFilter) + return AsRegistryV2Error(errors.New(msg)).WithStatus(http.StatusConflict) + } + } + + err = sd.CanSetupAccount(*account) + if err != nil { + return AsRegistryV2Error(fmt.Errorf("cannot set up backing storage for this account: %w", err)).WithStatus(http.StatusConflict) + } + + tx, err := db.Begin() + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + defer sqlext.RollbackUnlessCommitted(tx) + + err = tx.Insert(account) + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + // commit the changes + err = tx.Commit() + if err != nil { + return AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + return nil +}