diff --git a/cmd/api/main.go b/cmd/api/main.go index 584bac3e..7b2a5bcb 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -90,7 +90,7 @@ func run(cmd *cobra.Command, args []string) { corsMiddleware := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowedMethods: []string{"HEAD", "GET", "POST", "PUT", "DELETE"}, - AllowedHeaders: []string{"Content-Type", "User-Agent", "Authorization", "X-Auth-Token", "X-Keppel-Sublease-Token"}, + AllowedHeaders: []string{"Content-Type", "User-Agent", "Authorization", "X-Auth-Token", keppelv1.SubleaseHeader}, }) handler := httpapi.Compose( keppelv1.NewAPI(cfg, ad, fd, sd, icd, db, auditor, rle), diff --git a/internal/api/keppel/accounts.go b/internal/api/keppel/accounts.go index ebf05ebc..7bd4b5bb 100644 --- a/internal/api/keppel/accounts.go +++ b/internal/api/keppel/accounts.go @@ -22,13 +22,9 @@ import ( "context" "database/sql" "encoding/json" - "errors" "fmt" "net/http" - "reflect" - "regexp" "slices" - "strings" "time" "github.com/gorilla/mux" @@ -43,63 +39,6 @@ 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) { - gcPolicies, err := keppel.ParseGCPolicies(dbAccount) - if err != nil { - return Account{}, err - } - rbacPolicies, err := keppel.ParseRBACPolicies(dbAccount) - if err != nil { - return Account{}, err - } - if rbacPolicies == nil { - // do not render "null" in this field - rbacPolicies = []keppel.RBACPolicy{} - } - - metadata := make(map[string]string) - if dbAccount.MetadataJSON != "" { - err := json.Unmarshal([]byte(dbAccount.MetadataJSON), &metadata) - if err != nil { - return Account{}, fmt.Errorf("malformed metadata JSON: %q", dbAccount.MetadataJSON) - } - } - - return Account{ - Name: dbAccount.Name, - AuthTenantID: dbAccount.AuthTenantID, - GCPolicies: gcPolicies, - InMaintenance: dbAccount.InMaintenance, - Metadata: metadata, - RBACPolicies: rbacPolicies, - ReplicationPolicy: keppel.RenderReplicationPolicy(dbAccount), - ValidationPolicy: keppel.RenderValidationPolicy(dbAccount), - PlatformFilter: dbAccount.PlatformFilter, - }, nil -} - -//////////////////////////////////////////////////////////////////////////////// -// handlers - func (a *API) handleGetAccounts(w http.ResponseWriter, r *http.Request) { httpapi.IdentifyEndpoint(r, "/keppel/v1/accounts") var accounts []models.Account @@ -131,9 +70,9 @@ 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) + accountsRendered[idx], err = keppel.RenderAccount(account) if respondwith.ErrorText(w, err) { return } @@ -152,20 +91,18 @@ func (a *API) handleGetAccount(w http.ResponseWriter, r *http.Request) { return } - accountRendered, err := a.renderAccount(*account) + accountRendered, err := keppel.RenderAccount(*account) if respondwith.ErrorText(w, err) { return } 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() @@ -188,265 +125,19 @@ func (a *API) handlePutAccount(w http.ResponseWriter, r *http.Request) { 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) - return - } - - // 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 - } - - // 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 - } - - // 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) - } - - // serialize metadata - if len(req.Account.Metadata) == 0 { - targetAccount.MetadataJSON = "" - } else { - buf, _ := json.Marshal(req.Account.Metadata) - targetAccount.MetadataJSON = string(buf) - } - - // 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 - } - } - - 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 - } - - 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 - } - buf, _ := json.Marshal(req.Account.RBACPolicies) - targetAccount.RBACPoliciesJSON = string(buf) - } - - // validate validation policy - if req.Account.ValidationPolicy != nil { - rerr := req.Account.ValidationPolicy.ApplyToAccount(&targetAccount) - if rerr != nil { - rerr.WriteAsTextTo(w) - return - } - } - - // validate platform filter - 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 - } - } 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 - } - 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 - } - - 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 originalAccount == nil { - // sublease tokens are only relevant when creating replica accounts - subleaseTokenSecret := "" - if targetAccount.UpstreamPeerHostName != "" { - subleaseToken, err := SubleaseTokenFromRequest(r) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - 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(), targetAccount, 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 - } - - err = a.sd.CanSetupAccount(targetAccount) + account, rerr := a.processor().CreateOrUpdateAccount(r.Context(), req.Account, a.fd, authz.UserIdentity.UserInfo(), r, func(_ models.Peer) (string, *keppel.RegistryV2Error) { + subleaseToken, err := SubleaseTokenFromRequest(r) 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) - - err = tx.Insert(&targetAccount) - if respondwith.ErrorText(w, err) { - return - } - - // commit the changes - err = tx.Commit() - if respondwith.ErrorText(w, err) { - return - } - if userInfo := authz.UserIdentity.UserInfo(); userInfo != nil { - a.auditor.Record(audittools.EventParameters{ - Time: time.Now(), - Request: r, - User: userInfo, - ReasonCode: http.StatusOK, - Action: cadf.CreateAction, - Target: AuditAccount{Account: targetAccount}, - }) - } - } else { - // originalAccount != nil: update if necessary - if !reflect.DeepEqual(*originalAccount, targetAccount) { - _, err := a.db.Update(&targetAccount) - if respondwith.ErrorText(w, err) { - return - } - } - - // 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: targetAccount}, - }) - } + return "", keppel.AsRegistryV2Error(err) } + return subleaseToken.Secret, nil + }) + if rerr != nil { + rerr.WriteAsTextTo(w) + return } - accountRendered, err := a.renderAccount(targetAccount) + accountRendered, err := keppel.RenderAccount(account) if respondwith.ErrorText(w, err) { return } diff --git a/internal/api/keppel/accounts_test.go b/internal/api/keppel/accounts_test.go index fcf3bae3..ad8adf2f 100644 --- a/internal/api/keppel/accounts_test.go +++ b/internal/api/keppel/accounts_test.go @@ -33,6 +33,7 @@ import ( "github.com/sapcc/go-bits/assert" "github.com/sapcc/go-bits/easypg" + keppelv1 "github.com/sapcc/keppel/internal/api/keppel" "github.com/sapcc/keppel/internal/keppel" "github.com/sapcc/keppel/internal/models" "github.com/sapcc/keppel/internal/test" @@ -606,7 +607,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 (e.g. v1) are reserved for internal use\n"), }.Check(t, h) assert.HTTPRequest{ @@ -1291,8 +1292,8 @@ func TestGetPutAccountReplicationOnFirstUse(t *testing.T) { Method: "PUT", Path: "/keppel/v1/accounts/first", Header: map[string]string{ - "X-Test-Perms": "change:tenant1", - "X-Keppel-Sublease-Token": makeSubleaseToken("first", "registry.example.org", "not-the-valid-token"), + "X-Test-Perms": "change:tenant1", + keppelv1.SubleaseHeader: makeSubleaseToken("first", "registry.example.org", "not-the-valid-token"), }, Body: assert.JSONObject{ "account": assert.JSONObject{ @@ -1312,8 +1313,8 @@ func TestGetPutAccountReplicationOnFirstUse(t *testing.T) { Method: "PUT", Path: "/keppel/v1/accounts/first", Header: map[string]string{ - "X-Test-Perms": "change:tenant1", - "X-Keppel-Sublease-Token": makeSubleaseToken("first", "registry.example.org", "valid-token"), + "X-Test-Perms": "change:tenant1", + keppelv1.SubleaseHeader: makeSubleaseToken("first", "registry.example.org", "valid-token"), }, Body: assert.JSONObject{ "account": assert.JSONObject{ @@ -2137,8 +2138,8 @@ func TestReplicaAccountsInheritPlatformFilter(t *testing.T) { Method: "PUT", Path: "/keppel/v1/accounts/first", Header: map[string]string{ - "X-Test-Perms": "change:tenant1", - "X-Keppel-Sublease-Token": makeSubleaseToken("first", "registry.example.org", "valid-token"), + "X-Test-Perms": "change:tenant1", + keppelv1.SubleaseHeader: makeSubleaseToken("first", "registry.example.org", "valid-token"), }, Body: assert.JSONObject{ "account": assert.JSONObject{ @@ -2171,8 +2172,8 @@ func TestReplicaAccountsInheritPlatformFilter(t *testing.T) { Method: "PUT", Path: "/keppel/v1/accounts/second", Header: map[string]string{ - "X-Test-Perms": "change:tenant1", - "X-Keppel-Sublease-Token": makeSubleaseToken("second", "registry.example.org", "valid-token"), + "X-Test-Perms": "change:tenant1", + keppelv1.SubleaseHeader: makeSubleaseToken("second", "registry.example.org", "valid-token"), }, Body: assert.JSONObject{ "account": assert.JSONObject{ @@ -2209,8 +2210,8 @@ func TestReplicaAccountsInheritPlatformFilter(t *testing.T) { Method: "PUT", Path: "/keppel/v1/accounts/third", Header: map[string]string{ - "X-Test-Perms": "change:tenant1", - "X-Keppel-Sublease-Token": makeSubleaseToken("third", "registry.example.org", "valid-token"), + "X-Test-Perms": "change:tenant1", + keppelv1.SubleaseHeader: makeSubleaseToken("third", "registry.example.org", "valid-token"), }, Body: assert.JSONObject{ "account": assert.JSONObject{ diff --git a/internal/api/keppel/audit.go b/internal/api/keppel/audit.go index c96581ea..57fbe089 100644 --- a/internal/api/keppel/audit.go +++ b/internal/api/keppel/audit.go @@ -27,40 +27,6 @@ import ( "github.com/sapcc/keppel/internal/models" ) -// AuditAccount is an audittools.TargetRenderer. -type AuditAccount struct { - Account models.Account -} - -// Render implements the audittools.TargetRenderer interface. -func (a AuditAccount) Render() cadf.Resource { - res := cadf.Resource{ - TypeURI: "docker-registry/account", - ID: a.Account.Name, - ProjectID: a.Account.AuthTenantID, - } - - gcPoliciesJSON := a.Account.GCPoliciesJSON - if gcPoliciesJSON != "" && gcPoliciesJSON != "[]" { - res.Attachments = append(res.Attachments, cadf.Attachment{ - Name: "gc-policies", - TypeURI: "mime:application/json", - Content: a.Account.GCPoliciesJSON, - }) - } - - rbacPoliciesJSON := a.Account.RBACPoliciesJSON - if rbacPoliciesJSON != "" && rbacPoliciesJSON != "[]" { - res.Attachments = append(res.Attachments, cadf.Attachment{ - Name: "rbac-policies", - TypeURI: "mime:application/json", - Content: a.Account.RBACPoliciesJSON, - }) - } - - return res -} - // AuditQuotas is an audittools.TargetRenderer. type AuditQuotas struct { QuotasBefore models.Quotas diff --git a/internal/api/keppel/sublease.go b/internal/api/keppel/sublease.go index 4b1c9f21..bdfae4c2 100644 --- a/internal/api/keppel/sublease.go +++ b/internal/api/keppel/sublease.go @@ -25,6 +25,8 @@ import ( "net/http" ) +const SubleaseHeader = "X-Keppel-Sublease-Token" + // SubleaseToken is the internal structure of a sublease token. Only the secret // is passed on to the federation driver. The other attributes are only // informational. GUIs/CLIs can display these data to the user for confirmation @@ -51,13 +53,13 @@ func SubleaseTokenFromRequest(r *http.Request) (SubleaseToken, error) { buf, err := base64.StdEncoding.DecodeString(in) if err != nil { - return SubleaseToken{}, fmt.Errorf("malformed X-Keppel-Sublease-Token header: %s", err.Error()) + return SubleaseToken{}, fmt.Errorf("malformed %s header: %w", SubleaseHeader, err) } var t SubleaseToken err = json.Unmarshal(buf, &t) if err != nil { - return SubleaseToken{}, fmt.Errorf("malformed X-Keppel-Sublease-Token header: %s", err.Error()) + return SubleaseToken{}, fmt.Errorf("malformed %s header: %w", SubleaseHeader, err) } return t, nil } diff --git a/internal/client/peer/client.go b/internal/client/peer/client.go index dd760e93..16f8e74f 100644 --- a/internal/client/peer/client.go +++ b/internal/client/peer/client.go @@ -44,8 +44,7 @@ func New(ctx context.Context, cfg keppel.Configuration, peer models.Peer, scope c := Client{peer, ""} err := c.initToken(ctx, cfg, scope) if err != nil { - return Client{}, fmt.Errorf("while trying to obtain a peer token for %s in scope %s: %w", - peer.HostName, scope, err) + return Client{}, fmt.Errorf("while trying to obtain a peer token for %s in scope %s: %w", peer.HostName, scope, err) } return c, nil } diff --git a/internal/keppel/account.go b/internal/keppel/account.go new file mode 100644 index 000000000..bac58523 --- /dev/null +++ b/internal/keppel/account.go @@ -0,0 +1,75 @@ +/****************************************************************************** +* +* 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 ( + "encoding/json" + "fmt" + + "github.com/sapcc/keppel/internal/models" +) + +// 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"` +} + +// RenderAccount converts an account model from the DB into the API representation. +func RenderAccount(dbAccount models.Account) (Account, error) { + gcPolicies, err := ParseGCPolicies(dbAccount) + if err != nil { + return Account{}, err + } + rbacPolicies, err := ParseRBACPolicies(dbAccount) + if err != nil { + return Account{}, err + } + if rbacPolicies == nil { + // do not render "null" in this field + rbacPolicies = []RBACPolicy{} + } + + metadata := make(map[string]string) + if dbAccount.MetadataJSON != "" { + err := json.Unmarshal([]byte(dbAccount.MetadataJSON), &metadata) + if err != nil { + return Account{}, fmt.Errorf("malformed metadata JSON: %q", dbAccount.MetadataJSON) + } + } + + return Account{ + Name: dbAccount.Name, + AuthTenantID: dbAccount.AuthTenantID, + GCPolicies: gcPolicies, + InMaintenance: dbAccount.InMaintenance, + Metadata: metadata, + RBACPolicies: rbacPolicies, + ReplicationPolicy: RenderReplicationPolicy(dbAccount), + ValidationPolicy: RenderValidationPolicy(dbAccount), + PlatformFilter: dbAccount.PlatformFilter, + }, nil +} diff --git a/internal/keppel/peer.go b/internal/keppel/peer.go new file mode 100644 index 000000000..3fa29881 --- /dev/null +++ b/internal/keppel/peer.go @@ -0,0 +1,38 @@ +/******************************************************************************* +* +* 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 keppel + +import ( + "github.com/go-gorp/gorp/v3" + + "github.com/sapcc/keppel/internal/models" +) + +// GetPeerFromAccount returns the peer of the account given. +// +// Returns sql.ErrNoRows if the configured peer does not exist. +func GetPeerFromAccount(db gorp.SqlExecutor, account models.Account) (models.Peer, error) { + var peer models.Peer + err := db.SelectOne(&peer, `SELECT * FROM peers WHERE hostname = $1`, account.UpstreamPeerHostName) + if err != nil { + return models.Peer{}, err + } + return peer, nil +} diff --git a/internal/processor/accounts.go b/internal/processor/accounts.go index 1ad3bc9a..6fce4660 100644 --- a/internal/processor/accounts.go +++ b/internal/processor/accounts.go @@ -21,24 +21,28 @@ package processor import ( "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "regexp" + "strings" + "time" "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 - } + "github.com/sapcc/go-api-declarations/cadf" + "github.com/sapcc/go-bits/audittools" + "github.com/sapcc/go-bits/sqlext" +) +// GetPlatformFilterFromPrimaryAccount takes a replica account and queries the peer holding the primary account for that account. +func (p *Processor) GetPlatformFilterFromPrimaryAccount(ctx context.Context, peer models.Peer, replicaAccount models.Account) (models.PlatformFilter, error) { viewScope := auth.Scope{ ResourceType: "keppel_account", ResourceName: replicaAccount.Name, @@ -49,21 +53,262 @@ func (p *Processor) GetPlatformFilterFromPrimaryAccount(ctx context.Context, rep 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"` - } + var upstreamAccount keppel.Account err = client.GetForeignAccountConfigurationInto(ctx, &upstreamAccount, replicaAccount.Name) if err != nil { return nil, err } return upstreamAccount.PlatformFilter, nil } + +var looksLikeAPIVersionRx = regexp.MustCompile(`^v[0-9][1-9]*$`) + +// CreateOrUpdate can be used on an API account and returns the database representation of it. +func (p *Processor) CreateOrUpdateAccount(ctx context.Context, account keppel.Account, fd keppel.FederationDriver, userInfo audittools.UserInfo, r *http.Request, getSubleaseToken func(models.Peer) (string, *keppel.RegistryV2Error)) (models.Account, *keppel.RegistryV2Error) { + // 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(account.Name, "keppel") { + return models.Account{}, keppel.AsRegistryV2Error(errors.New(`account names with the prefix "keppel" are reserved for internal use`)).WithStatus(http.StatusUnprocessableEntity) + } + if looksLikeAPIVersionRx.MatchString(account.Name) { + return models.Account{}, keppel.AsRegistryV2Error(errors.New(`account names that look like API versions (e.g. v1) are reserved for internal use`)).WithStatus(http.StatusUnprocessableEntity) + } + + // check if account already exists + originalAccount, err := keppel.FindAccount(p.db, account.Name) + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + if originalAccount != nil && originalAccount.AuthTenantID != account.AuthTenantID { + return models.Account{}, keppel.AsRegistryV2Error(errors.New(`account name already in use by a different tenant`)).WithStatus(http.StatusConflict) + } + + // 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: account.Name, + AuthTenantID: account.AuthTenantID, + SecurityScanPoliciesJSON: "[]", + // all other attributes are set below or in the ApplyToAccount() methods called below + } + } else { + targetAccount = *originalAccount + } + + // validate and update fields as requested + targetAccount.InMaintenance = account.InMaintenance + + // validate GC policies + if len(account.GCPolicies) == 0 { + targetAccount.GCPoliciesJSON = "[]" + } else { + for _, policy := range account.GCPolicies { + err := policy.Validate() + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + } + } + buf, _ := json.Marshal(account.GCPolicies) + targetAccount.GCPoliciesJSON = string(buf) + } + + // serialize metadata + if len(account.Metadata) == 0 { + targetAccount.MetadataJSON = "" + } else { + buf, _ := json.Marshal(account.Metadata) + targetAccount.MetadataJSON = string(buf) + } + + // 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 + } + } + + var replicationStrategy keppel.ReplicationStrategy + if 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 := *account.ReplicationPolicy + if originalAccount != nil && originalStrategy != rp.Strategy { + return models.Account{}, keppel.AsRegistryV2Error(keppel.ErrIncompatibleReplicationPolicy).WithStatus(http.StatusConflict) + } + + err := rp.ApplyToAccount(&targetAccount) + if errors.Is(err, keppel.ErrIncompatibleReplicationPolicy) { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusConflict) + } else if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + } + replicationStrategy = rp.Strategy + } + + // validate RBAC policies + if len(account.RBACPolicies) == 0 { + targetAccount.RBACPoliciesJSON = "" + } else { + for idx, policy := range account.RBACPolicies { + err := policy.ValidateAndNormalize(replicationStrategy) + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusUnprocessableEntity) + } + account.RBACPolicies[idx] = policy + } + buf, _ := json.Marshal(account.RBACPolicies) + targetAccount.RBACPoliciesJSON = string(buf) + } + + // validate validation policy + if account.ValidationPolicy != nil { + rerr := account.ValidationPolicy.ApplyToAccount(&targetAccount) + if rerr != nil { + return models.Account{}, rerr + } + } + + var peer models.Peer + if targetAccount.UpstreamPeerHostName != "" { + // NOTE: This validates UpstreamPeerHostName as a side effect. + peer, err = keppel.GetPeerFromAccount(p.db, targetAccount) + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Errorf(`unknown peer registry: %q`, targetAccount.UpstreamPeerHostName) + return models.Account{}, keppel.AsRegistryV2Error(msg).WithStatus(http.StatusUnprocessableEntity) + } + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + } + + // validate platform filter + if originalAccount == nil { + switch replicationStrategy { + case keppel.NoReplicationStrategy: + if account.PlatformFilter != nil { + return models.Account{}, keppel.AsRegistryV2Error(errors.New(`platform filter is only allowed on replica accounts`)).WithStatus(http.StatusUnprocessableEntity) + } + case keppel.FromExternalOnFirstUseStrategy: + targetAccount.PlatformFilter = 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 + upstreamPlatformFilter, err := p.GetPlatformFilterFromPrimaryAccount(ctx, peer, targetAccount) + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + if account.PlatformFilter != nil && !upstreamPlatformFilter.IsEqualTo(account.PlatformFilter) { + jsonPlatformFilter, _ := json.Marshal(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) + return models.Account{}, keppel.AsRegistryV2Error(errors.New(msg)).WithStatus(http.StatusConflict) + } + targetAccount.PlatformFilter = upstreamPlatformFilter + } + } else if account.PlatformFilter != nil && !originalAccount.PlatformFilter.IsEqualTo(account.PlatformFilter) { + return models.Account{}, keppel.AsRegistryV2Error(errors.New(`cannot change platform filter on existing account`)).WithStatus(http.StatusConflict) + } + + // create account if required + if originalAccount == nil { + // sublease tokens are only relevant when creating replica accounts + subleaseTokenSecret := "" + if targetAccount.UpstreamPeerHostName != "" { + var rerr *keppel.RegistryV2Error + subleaseTokenSecret, rerr = getSubleaseToken(peer) + if rerr != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusBadRequest) + } + } + + // 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, targetAccount, subleaseTokenSecret) + switch claimResult { + case keppel.ClaimSucceeded: + // nothing to do + case keppel.ClaimFailed: + // user error + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusForbidden) + case keppel.ClaimErrored: + // server error + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + err = p.sd.CanSetupAccount(targetAccount) + if err != nil { + msg := fmt.Errorf("cannot set up backing storage for this account: %w", err) + return models.Account{}, keppel.AsRegistryV2Error(msg).WithStatus(http.StatusConflict) + } + + tx, err := p.db.Begin() + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + defer sqlext.RollbackUnlessCommitted(tx) + + err = tx.Insert(&targetAccount) + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + // commit the changes + err = tx.Commit() + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + + if userInfo != nil { + p.auditor.Record(audittools.EventParameters{ + Time: time.Now(), + Request: r, + User: userInfo, + ReasonCode: http.StatusOK, + Action: cadf.CreateAction, + Target: AuditAccount{Account: targetAccount}, + }) + } + } else { + // originalAccount != nil: update if necessary + if !reflect.DeepEqual(*originalAccount, targetAccount) { + _, err := p.db.Update(&targetAccount) + if err != nil { + return models.Account{}, keppel.AsRegistryV2Error(err).WithStatus(http.StatusInternalServerError) + } + } + + // audit log is necessary for all changes except to InMaintenance + if userInfo != nil { + originalAccount.InMaintenance = targetAccount.InMaintenance + if !reflect.DeepEqual(*originalAccount, targetAccount) { + p.auditor.Record(audittools.EventParameters{ + Time: time.Now(), + Request: r, + User: userInfo, + ReasonCode: http.StatusOK, + Action: cadf.UpdateAction, + Target: AuditAccount{Account: targetAccount}, + }) + } + } + } + + return targetAccount, nil +} diff --git a/internal/processor/audit.go b/internal/processor/audit.go new file mode 100644 index 000000000..f11596aa --- /dev/null +++ b/internal/processor/audit.go @@ -0,0 +1,60 @@ +/******************************************************************************* +* +* 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 ( + "github.com/sapcc/go-api-declarations/cadf" + + "github.com/sapcc/keppel/internal/models" +) + +// AuditAccount is an audittools.TargetRenderer. +type AuditAccount struct { + Account models.Account +} + +// Render implements the audittools.TargetRenderer interface. +func (a AuditAccount) Render() cadf.Resource { + res := cadf.Resource{ + TypeURI: "docker-registry/account", + ID: a.Account.Name, + ProjectID: a.Account.AuthTenantID, + } + + gcPoliciesJSON := a.Account.GCPoliciesJSON + if gcPoliciesJSON != "" && gcPoliciesJSON != "[]" { + res.Attachments = append(res.Attachments, cadf.Attachment{ + Name: "gc-policies", + TypeURI: "mime:application/json", + Content: a.Account.GCPoliciesJSON, + }) + } + + rbacPoliciesJSON := a.Account.RBACPoliciesJSON + if rbacPoliciesJSON != "" && rbacPoliciesJSON != "[]" { + res.Attachments = append(res.Attachments, cadf.Attachment{ + Name: "rbac-policies", + TypeURI: "mime:application/json", + Content: a.Account.RBACPoliciesJSON, + }) + } + + return res +}