Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract account logic #364

Merged
merged 4 commits into from Apr 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/api/main.go
Expand Up @@ -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),
Expand Down
335 changes: 13 additions & 322 deletions internal/api/keppel/accounts.go
Expand Up @@ -22,13 +22,9 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"reflect"
"regexp"
"slices"
"strings"
"time"

"github.com/gorilla/mux"
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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()
Expand All @@ -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
}
Expand Down