mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-30 16:06:22 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bd7028329 | |||
| 237fcdff94 | |||
| 5dd95b1ecd | |||
| 63cf1beaa7 |
@@ -160,6 +160,4 @@ var allowlist = []allowance{
|
||||
{From: "domains/rds/instance", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "domains/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
|
||||
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
}
|
||||
|
||||
@@ -353,12 +353,13 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
|
||||
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
|
||||
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
|
||||
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` stays on its chi route in `gateway/admin*.go` until the actor carries `UserName`. |
|
||||
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` moved onto the same registration in `mulga-3nbla.7`, recorded below. |
|
||||
| `spinifex#1344` | `spinifex/gateway/rds.go` (`RDS_Request`, `rdsCaller`) | `spinifex/domains/rds/awsapi` (`NewRegistration`, `Deps`, `Quota`, `CallerFromActor`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | RDS registers through the ADR-0002 seam with the XML envelope it had. The check order is kept: query parse, action, account, the principal-class gate for the internal agent actions, scope, authorization, NATS, quota and dispatch, so a denied caller still gets `AccessDenied` before the handler runs and no customer grant is evaluated against an agent action. The gateway members RDS read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `ExpectedNodes` as `Deps.ExpectedNodes`, `Quota.EnforceRDSInstances` as the `Deps.Quota` interface, `Region` as `Invocation.Region` and the policy gate as `Invocation.Authorize`. A nil `Deps.Quota` skips the DB-instance cap. The caller comes from `dispatch.Actor`: the session name for an assumed-role session and the user name otherwise, and the role name from `Actor.RoleName()`, which parses the underlying role ARN and never the session name. The account comes from `Invocation.AccountID`, so a caller with no composable ARN still reaches the policy check and is refused there as before. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/rds/awsapi` allowlist row are removed, and `cmd/aws-model-coverage` reads the inventory under a fifth named-exception allowlist row. The characterization tests that pin the pipeline behaviour were committed first with spinifex#1336 and pass unchanged against the registration; only the `withRDS` and `serveRDS` test helpers and the setup of the direct-call RDS tests changed. |
|
||||
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
|
||||
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
|
||||
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
|
||||
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
|
||||
| `spinifex#1345` | `spinifex/gateway/admin*.go` (the `/admin/{method}` handlers, gates and body cap) and the `AccountQuotaRequest`/`AccountQuotaResponse`/`DeleteAccountResponse` wire types | `spinifex/operator/admin` (`rest.go`, `account_*.go`), served by the same `spinifex` registration | `/admin/{method}` stays a chi route in `gateway`, ahead of the service selector, but `Admin_Request` now only mints the request ID and header and hands the request to the registered entry through `dispatchRegistered`, the path every Query request takes. `dispatch.Invocation` gains `Service`, the credential-scope service, so the scope gate is the first identity gate in the dispatcher and a key signed for another service is denied exactly as before. The order is unchanged: verb and method, then the five fail-closed gates (scope, super-admin account, IAM user, identity, `spinifex:<Method>` policy), then the cluster check, then the 8 KiB body cap. The dispatcher returns errors and the route renders them as the JSON envelope with `requestId`, then logs once from the real result, so a denial or a 5xx is never logged as a completed request; the registration's `ResolveAction` labels the method, which removes the `auth.go` special case. The caller is read from `dispatch.Actor` (`UserName` for the gate and `updatedBy`). `BucketStore` and `SignupMaxAccounts` leave `GatewayConfig` for `operator/admin.Deps`, composed in `awsgw` beside `IAM`, `Quota`, `ExpectedNodes` and `NodeIDs`. The three wire types that could not enter `contracts` move to `operator/admin` with their handlers, `operator/cli` imports them from there, and the last `operator/cli` → `gateway` allowlist entry is deleted. |
|
||||
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
|
||||
The EC2 contract row is intentionally different from the directory moves: it creates
|
||||
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
|
||||
@@ -463,7 +464,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `operator/installer/firstboot` (security): `Config.InstallCallback` is rendered into `spinifex-firstboot.sh`, which is world-readable (0755). The field is currently unused (`fireInstallCallback` reads `SPINIFEX_INSTALL_CALLBACK` from the environment instead), but any future wiring must not place secrets or tokenised callback URLs in the generated script; sensitive callback credentials should reach first boot through a protected channel such as a 0600 file or a credential store.
|
||||
- `operator/cli` (known security risk): `spx admin join` contacts the formation server with `InsecureSkipVerify`, sends the join token as a bearer credential and receives the cluster CA certificate and key, master key, Viperblock key and shared credentials, so an on-path attacker during formation can capture the token and the cluster root secrets or substitute a CA. This predates the move; lint surfaced it when the tree left `cmd/`. It is accepted for now and carries a line-level gosec annotation stating it is not a false positive. Removal condition: authenticate formation TLS with a pinned key or certificate delivered with the join material, or an out-of-band CA, with tests proving a mismatched server certificate fails before credentials are sent.
|
||||
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
|
||||
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
@@ -683,7 +684,7 @@ series above; "needs decision" is open.
|
||||
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
|
||||
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch that calls them now lives in `operator/admin` as a seam registration; `/admin/{method}` stays in the root `gateway` until the actor carries `UserName`. |
|
||||
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
|
||||
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
|
||||
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
|
||||
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
|
||||
@@ -703,7 +704,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
|
||||
| Class | Paths | Status |
|
||||
|---|---|---|
|
||||
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Open: the owner of the `spinifex` service and `/admin` dispatch registration | Each needs one decision or one capability, then a mechanical move. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Done: the `spinifex` service and `/admin` dispatch registration, owned by `operator/admin` | Each needs one decision or one capability, then a mechanical move. |
|
||||
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
|
||||
|
||||
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
|
||||
|
||||
+22
-169
@@ -1,65 +1,41 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"uuid"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
|
||||
operatoradmin "github.com/mulgadc/spinifex/spinifex/operator/admin"
|
||||
)
|
||||
|
||||
// adminMethods is the callable /admin/<Method> set, built from the contract's
|
||||
// list. A method absent here is rejected before any parsing, so adding a
|
||||
// handler is a deliberate act rather than a side effect of adding a function.
|
||||
var adminMethods = func() map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for _, name := range operatorv1.AdminMethodNames() {
|
||||
m[name] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
// Admin_Request serves the private /admin/<Method> surface. The route stays
|
||||
// here so it keeps its place ahead of the service selector; the work is the
|
||||
// spinifex registration's, dispatched exactly as a Query request is. Only the
|
||||
// error rendering differs: this surface answers in JSON with a request ID.
|
||||
func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewV4().String()
|
||||
w.Header().Set("X-Amzn-Requestid", requestID)
|
||||
r = r.WithContext(operatoradmin.WithRequestID(r.Context(), requestID))
|
||||
|
||||
// adminPathPrefix is the private admin surface's URL prefix. It is matched
|
||||
// before routing so the auth middleware can label the request by method.
|
||||
const adminPathPrefix = "/admin/"
|
||||
entry, ok := gw.registered(operatoradmin.ServiceName)
|
||||
if !ok {
|
||||
slog.Error("Admin: spinifex service is not registered", "requestId", requestID)
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
|
||||
return
|
||||
}
|
||||
|
||||
// adminPathMethod extracts the method from an /admin/<Method> path. It reports
|
||||
// false for anything else, including a nested path, so only the exact shape the
|
||||
// router serves is labelled.
|
||||
func adminPathMethod(path string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(path, adminPathPrefix)
|
||||
if !ok || rest == "" || strings.Contains(rest, "/") {
|
||||
return "", false
|
||||
action, _ := r.Context().Value(ctxAction).(string)
|
||||
if err := gw.dispatchRegistered(entry, w, r); err != nil {
|
||||
slog.Log(r.Context(), serviceErrorLevel(operatoradmin.ServiceName, err), "Service request error",
|
||||
"service", operatoradmin.ServiceName, "action", action, "requestId", requestID, "error", err)
|
||||
gw.writeAdminErrorFor(w, requestID, err)
|
||||
return
|
||||
}
|
||||
if !adminMethods[rest] {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
slog.Info("Service request completed", "service", operatoradmin.ServiceName, "action", action, "requestId", requestID)
|
||||
}
|
||||
|
||||
// ctxAdminRequestID carries the /admin request ID to handlers that log under it.
|
||||
// It correlates log records only and authorises nothing.
|
||||
const ctxAdminRequestID contextKey = "admin.requestID"
|
||||
|
||||
func adminRequestIDFrom(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxAdminRequestID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// adminMaxBodyBytes caps the JSON request body. The largest legitimate request
|
||||
// is a few hundred bytes; the cap exists so an unauthenticated-shaped mistake
|
||||
// cannot make the gateway buffer megabytes.
|
||||
const adminMaxBodyBytes = 8 << 10
|
||||
|
||||
// adminErrorBody is the JSON error envelope for /admin. This surface is not an
|
||||
// AWS API, so it does not use the EC2 or IAM XML envelopes.
|
||||
type adminErrorBody struct {
|
||||
@@ -72,129 +48,6 @@ type adminErrorDetail struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// Admin_Request dispatches the private /admin/<Method> surface: super-admin
|
||||
// only, JSON in and JSON out, SigV4-authenticated by the shared middleware.
|
||||
//
|
||||
// Authorization is fail-closed at every step and every denial returns the same
|
||||
// AccessDenied, so a caller cannot probe which gate rejected it.
|
||||
func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewV4().String()
|
||||
w.Header().Set("X-Amzn-Requestid", requestID)
|
||||
|
||||
method := chi.URLParam(r, "method")
|
||||
ctx := context.WithValue(r.Context(), ctxAdminRequestID, requestID)
|
||||
|
||||
// One handler for every verb rather than a POST route plus a catch-all:
|
||||
// chi resolves the two to the same pattern, and the catch-all wins.
|
||||
if r.Method != http.MethodPost {
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorMethodNotAllowed, "")
|
||||
return
|
||||
}
|
||||
|
||||
if !adminMethods[method] {
|
||||
slog.Debug("Admin: unknown method", "method", method)
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorInvalidAction, "")
|
||||
return
|
||||
}
|
||||
|
||||
if err := gw.authorizeAdmin(r, method); err != nil {
|
||||
gw.writeAdminErrorFor(w, requestID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil || !gw.NATSConn.IsConnected() {
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorServiceUnavailable, clusterUnavailableMsg)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, adminMaxBodyBytes+1))
|
||||
if err != nil {
|
||||
slog.Error("Admin: failed to read request body", "method", method, "err", err)
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
|
||||
return
|
||||
}
|
||||
if len(body) > adminMaxBodyBytes {
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorInvalidRequest, "request body exceeds 8 KiB")
|
||||
return
|
||||
}
|
||||
|
||||
var output any
|
||||
switch method {
|
||||
case "CreateAccount":
|
||||
output, err = gw.adminCreateAccount(ctx, body)
|
||||
case "DeleteAccount":
|
||||
output, err = gw.adminDeleteAccount(ctx, body)
|
||||
case "DescribeAccountDeletion":
|
||||
output, err = gw.adminDescribeAccountDeletion(ctx, body)
|
||||
case "ListAccounts":
|
||||
output, err = gw.adminListAccounts(ctx, body)
|
||||
case "GetAccountQuota":
|
||||
output, err = gw.adminGetAccountQuota(ctx, body)
|
||||
case "PutAccountQuota":
|
||||
output, err = gw.adminPutAccountQuota(ctx, body)
|
||||
default:
|
||||
// Unreachable: adminMethods gates the switch. Fail closed anyway so
|
||||
// adding a name to the map without a case cannot silently return 200.
|
||||
err = errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
if err != nil {
|
||||
gw.writeAdminErrorFor(w, requestID, err)
|
||||
return
|
||||
}
|
||||
|
||||
jsonOutput, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
slog.Error("Admin: failed to marshal response", "method", method, "err", err)
|
||||
gw.writeAdminError(w, requestID, awserrors.ErrorInternalError, "")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(jsonOutput); err != nil {
|
||||
slog.Error("Admin: failed to write response", "method", method, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeAdmin runs the five fail-closed gates. Each returns the same
|
||||
// AccessDenied so the response cannot be used to enumerate which condition the
|
||||
// caller failed; the log line names it for operators.
|
||||
func (gw *GatewayConfig) authorizeAdmin(r *http.Request, method string) error {
|
||||
ctx := r.Context()
|
||||
service, _ := ctx.Value(ctxService).(string)
|
||||
actor, _ := callerActor(ctx)
|
||||
accountID, identity, principalType := actor.AccountID, actorName(actor), actor.PrincipalType
|
||||
|
||||
deny := func(reason string) error {
|
||||
slog.Warn("Admin: access denied", "method", method, "reason", reason,
|
||||
"service", service, "accountID", accountID, "identity", identity,
|
||||
"principalType", principalType, "sourceIP", ingresshttp.ClientIP(r.RemoteAddr))
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
|
||||
// The credential scope must be spinifex: a key signed for ec2 reaching this
|
||||
// path means the caller is probing, not calling.
|
||||
if service != "spinifex" {
|
||||
return deny("credential scope is not spinifex")
|
||||
}
|
||||
if accountID != awsidentifiers.DefaultAccountID() {
|
||||
return deny("not the super-admin account")
|
||||
}
|
||||
// Long-lived IAM users only. This excludes assumed-role sessions outright,
|
||||
// so a role session cannot reach account creation even from this account.
|
||||
if principalType != principalTypeUser {
|
||||
return deny("principal is not an IAM user")
|
||||
}
|
||||
if identity == "" {
|
||||
return deny("no identity in auth context")
|
||||
}
|
||||
|
||||
// Defence in depth and the actual grant: the principal must hold
|
||||
// spinifex:<Method>. checkPolicy fails closed on a nil IAM service, an
|
||||
// unauthenticated request and an unresolvable principal.
|
||||
return gw.checkPolicy(r, "spinifex", method)
|
||||
}
|
||||
|
||||
// writeAdminErrorFor resolves an error to its code and any message its call
|
||||
// site attached, so a wrapped error renders as its own code instead of falling
|
||||
// through to InternalError.
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/audit"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type loggedRecord struct {
|
||||
Level string `json:"level"`
|
||||
Msg string `json:"msg"`
|
||||
Status float64 `json:"status"`
|
||||
}
|
||||
|
||||
// logRecords parses the JSON log lines captured while a request ran.
|
||||
func logRecords(t *testing.T, logs *bytes.Buffer) []loggedRecord {
|
||||
t.Helper()
|
||||
var out []loggedRecord
|
||||
for line := range strings.SplitSeq(logs.String(), "\n") {
|
||||
var rec loggedRecord
|
||||
if json.Unmarshal([]byte(line), &rec) == nil && rec.Msg != "" {
|
||||
out = append(out, rec)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func findRecord(recs []loggedRecord, msg string) (loggedRecord, bool) {
|
||||
for _, rec := range recs {
|
||||
if rec.Msg == msg {
|
||||
return rec, true
|
||||
}
|
||||
}
|
||||
return loggedRecord{}, false
|
||||
}
|
||||
|
||||
// The outcome a /admin request is logged with must be the one the caller got:
|
||||
// a denial or a fault is never recorded as a completed request.
|
||||
func TestAdminOutcome_LogFollowsTheRealResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
mutate func(*GatewayConfig)
|
||||
wantStatus int
|
||||
wantLevel string
|
||||
}{
|
||||
{"success", awsidentifiers.DefaultAccountID(), nil, http.StatusOK, ""},
|
||||
{"denied caller", authzAccountID, nil, http.StatusForbidden, "INFO"},
|
||||
{"cluster fault", awsidentifiers.DefaultAccountID(), func(gw *GatewayConfig) { gw.NATSConn = nil }, http.StatusServiceUnavailable, "ERROR"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, app := operatorPipelineApp(t, tc.accountID, allowAllOperatorDocs(), tc.mutate)
|
||||
logs := &bytes.Buffer{}
|
||||
prev := slog.Default()
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(logs, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||
|
||||
resp := adminCall(t, audit.RequestLogger(app), http.MethodPost, "ListAccounts", "spinifex", `{}`)
|
||||
require.Equal(t, tc.wantStatus, resp.StatusCode)
|
||||
|
||||
recs := logRecords(t, logs)
|
||||
access, ok := findRecord(recs, "request")
|
||||
require.True(t, ok, "no access record in:\n%s", logs.String())
|
||||
assert.EqualValues(t, tc.wantStatus, access.Status)
|
||||
|
||||
_, completed := findRecord(recs, "Service request completed")
|
||||
failure, failed := findRecord(recs, "Service request error")
|
||||
if tc.wantLevel == "" {
|
||||
assert.True(t, completed, "a success must be logged as completed")
|
||||
assert.False(t, failed, "a success must not be logged as an error")
|
||||
return
|
||||
}
|
||||
assert.False(t, completed, "a failed request must never be logged as completed")
|
||||
require.True(t, failed, "a failed request must be logged as an error")
|
||||
assert.Equal(t, tc.wantLevel, failure.Level)
|
||||
})
|
||||
}
|
||||
}
|
||||
+15
-151
@@ -7,10 +7,8 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -22,30 +20,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAdminPathMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"known method", "/admin/CreateAccount", "CreateAccount", true},
|
||||
{"deletion method", "/admin/DeleteAccount", "DeleteAccount", true},
|
||||
{"unknown method", "/admin/PurgeEverything", "", false},
|
||||
{"nested path", "/admin/CreateAccount/extra", "", false},
|
||||
{"bare prefix", "/admin/", "", false},
|
||||
{"not admin", "/", "", false},
|
||||
{"case mismatch", "/admin/createaccount", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := adminPathMethod(tc.path)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createAccountPolicy grants exactly the action the signup principal holds.
|
||||
func createAccountPolicy() *policyMockIAMService {
|
||||
allow := []iamdomain.PolicyDocument{{
|
||||
@@ -64,105 +38,6 @@ func createAccountPolicy() *policyMockIAMService {
|
||||
}
|
||||
}
|
||||
|
||||
// Every gate must deny on its own, and every denial must be indistinguishable
|
||||
// from the others so a caller cannot learn which one it failed.
|
||||
func TestAuthorizeAdminGates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
service string
|
||||
accountID string
|
||||
identity string
|
||||
principalType string
|
||||
policy *policyMockIAMService
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "permitted signup principal",
|
||||
service: "spinifex",
|
||||
accountID: awsidentifiers.DefaultAccountID(),
|
||||
identity: "signup",
|
||||
principalType: principalTypeUser,
|
||||
policy: createAccountPolicy(),
|
||||
},
|
||||
{
|
||||
name: "credential scope is not spinifex",
|
||||
service: "ec2",
|
||||
accountID: awsidentifiers.DefaultAccountID(),
|
||||
identity: "signup",
|
||||
principalType: principalTypeUser,
|
||||
policy: createAccountPolicy(),
|
||||
wantErr: awserrors.ErrorAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "not the super-admin account",
|
||||
service: "spinifex",
|
||||
accountID: "123456789012",
|
||||
identity: "signup",
|
||||
principalType: principalTypeUser,
|
||||
policy: createAccountPolicy(),
|
||||
wantErr: awserrors.ErrorAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "assumed-role session",
|
||||
service: "spinifex",
|
||||
accountID: awsidentifiers.DefaultAccountID(),
|
||||
identity: "signup",
|
||||
principalType: principalTypeAssumedRole,
|
||||
policy: createAccountPolicy(),
|
||||
wantErr: awserrors.ErrorAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "no identity",
|
||||
service: "spinifex",
|
||||
accountID: awsidentifiers.DefaultAccountID(),
|
||||
identity: "",
|
||||
principalType: principalTypeUser,
|
||||
policy: createAccountPolicy(),
|
||||
wantErr: awserrors.ErrorAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "policy does not grant CreateAccount",
|
||||
service: "spinifex",
|
||||
accountID: awsidentifiers.DefaultAccountID(),
|
||||
identity: "signup",
|
||||
principalType: principalTypeUser,
|
||||
policy: &policyMockIAMService{},
|
||||
wantErr: awserrors.ErrorAccessDenied,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: tc.policy})
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/CreateAccount", nil)
|
||||
ctx := context.WithValue(req.Context(), ctxService, tc.service)
|
||||
ctx = withPrincipalField(ctx, fieldAccountID, tc.accountID)
|
||||
ctx = withPrincipalField(ctx, fieldIdentity, tc.identity)
|
||||
ctx = withPrincipalField(ctx, fieldPrincipalType, tc.principalType)
|
||||
|
||||
err := gw.authorizeAdmin(req.WithContext(ctx), "CreateAccount")
|
||||
if tc.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tc.wantErr, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unauthenticated request carries no auth context at all, which must not
|
||||
// read as a permitted principal.
|
||||
func TestAuthorizeAdminDeniesUnauthenticated(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/CreateAccount", nil)
|
||||
|
||||
err := gw.authorizeAdmin(req, "CreateAccount")
|
||||
require.Error(t, err)
|
||||
assertDenied(t, err)
|
||||
}
|
||||
|
||||
// adminRequest drives Admin_Request with the chi route param the router sets.
|
||||
func adminRequest(gw *GatewayConfig, method string, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/"+method, strings.NewReader(body))
|
||||
@@ -185,7 +60,7 @@ func decodeAdminError(t *testing.T, rec *httptest.ResponseRecorder) adminErrorBo
|
||||
// An unknown method is rejected before authorization, so it never discloses
|
||||
// whether the caller would have been permitted.
|
||||
func TestAdminRequestUnknownMethod(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
|
||||
|
||||
rec := adminRequest(gw, "PurgeEverything", `{}`)
|
||||
|
||||
@@ -194,7 +69,7 @@ func TestAdminRequestUnknownMethod(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
|
||||
|
||||
rec := adminRequest(gw, "CreateAccount", `{}`)
|
||||
|
||||
@@ -206,7 +81,7 @@ func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
|
||||
// The request ID ties a client failure to the gateway log line, so it must be
|
||||
// present on the header and in the body even for a denial.
|
||||
func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
|
||||
|
||||
rec := adminRequest(gw, "CreateAccount", `{}`)
|
||||
|
||||
@@ -229,7 +104,7 @@ func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
|
||||
// The router serves every verb from one pattern, so the handler is what
|
||||
// refuses a GET — a second route would shadow the POST one.
|
||||
func TestAdminRequestRejectsNonPost(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()})
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: allowAllIAMService()}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/CreateAccount", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("method", "CreateAccount")
|
||||
@@ -269,34 +144,23 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
|
||||
// An authorized caller must still get a clear answer when the cluster is not
|
||||
// reachable, rather than a panic or a silent success.
|
||||
func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()})
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
|
||||
|
||||
rec := authorizedAdminRequest(gw, `{}`)
|
||||
|
||||
assert.Equal(t, awserrors.ErrorServiceUnavailable, decodeAdminError(t, rec).Error.Code)
|
||||
}
|
||||
|
||||
// The credential-minting CLI grants these by name. A method served but absent
|
||||
// from the list would be uncallable by every key an operator issues.
|
||||
func TestAdminMethodNamesCoverTheRoutedMethods(t *testing.T) {
|
||||
names := operatorv1.AdminMethodNames()
|
||||
// Each admin method is a separate grant. The signup Worker holds
|
||||
// spinifex:CreateAccount and must not reach deletion with it.
|
||||
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
|
||||
gw := withOperator(withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()}))
|
||||
|
||||
require.Len(t, names, len(adminMethods))
|
||||
assert.True(t, sort.StringsAreSorted(names), "names must be sorted for stable help text")
|
||||
for _, name := range names {
|
||||
method, ok := adminPathMethod(adminPathPrefix + name)
|
||||
assert.True(t, ok, "%s is granted but not routed", name)
|
||||
assert.Equal(t, name, method)
|
||||
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
rec := authorizedAdminRequestForMethod(gw, method, `{}`)
|
||||
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, decodeAdminError(t, rec).Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPathMethod_AcceptsExactlyTheContractList(t *testing.T) {
|
||||
for _, name := range operatorv1.AdminMethodNames() {
|
||||
got, ok := adminPathMethod("/admin/" + name)
|
||||
require.True(t, ok, name)
|
||||
require.Equal(t, name, got)
|
||||
}
|
||||
_, ok := adminPathMethod("/admin/NotInTheContractList")
|
||||
require.False(t, ok)
|
||||
require.Len(t, adminMethods, len(operatorv1.AdminMethodNames()))
|
||||
}
|
||||
|
||||
@@ -229,13 +229,6 @@ func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// /admin carries its method in the path and a JSON body, so the
|
||||
// query-arg parse above finds no Action. Without this every admin
|
||||
// method would share one "unknown" throttle bucket and trace label.
|
||||
if method, ok := adminPathMethod(r.URL.Path); ok {
|
||||
ctx = context.WithValue(ctx, ctxAction, method)
|
||||
}
|
||||
|
||||
// A registered service names its own action; everything else keeps
|
||||
// resolving JSON-1.1/path-routed REST-JSON actions through the legacy
|
||||
// resolver, since those carry no query-protocol Action to parse above.
|
||||
|
||||
@@ -43,7 +43,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
|
||||
accountteardown "github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
@@ -132,12 +131,7 @@ type GatewayConfig struct {
|
||||
// Zero value keeps the pre-existing fan-out-only behaviour.
|
||||
InstanceStatus ec2instanceapi.StatusSynthesis
|
||||
IAMService iamdomain.IAMService
|
||||
// BucketStore reaps a tenant's S3 buckets during account teardown. It
|
||||
// signs with the config service credential, which predastore already
|
||||
// trusts to reach any bucket, so this grants enumeration, not access.
|
||||
// Nil makes DeleteAccount refuse rather than tear down around the data.
|
||||
BucketStore accountteardown.BucketStore
|
||||
STSService stsdomain.STSService
|
||||
STSService stsdomain.STSService
|
||||
|
||||
// Credentials verifies SigV4 credentials. Nil fails every request closed.
|
||||
Credentials credential.Verifier
|
||||
@@ -206,11 +200,6 @@ type GatewayConfig struct {
|
||||
// Nil leaves topicPolicy on its literal (word-boundary) matcher alone.
|
||||
BedrockEmbedder ochre.Embedder
|
||||
|
||||
// SignupMaxAccounts caps how many accounts /admin/CreateAccount will allow
|
||||
// to exist. Zero means uncapped, which is the behaviour of every cluster
|
||||
// that has not opted into self-service signup.
|
||||
SignupMaxAccounts int
|
||||
|
||||
// BedrockAgentKB and BedrockAgentDataSources persist bedrock-agent
|
||||
// knowledge-base and data-source resource metadata (D-arch: gateway-owned,
|
||||
// not the daemon-owned vector engine). Nil for either fails
|
||||
@@ -580,6 +569,7 @@ func (gw *GatewayConfig) dispatchRegistered(entry *dispatch.Entry, w http.Respon
|
||||
return entry.Dispatch(w, dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: callerPrincipalOf(r).accountID,
|
||||
Service: requestService(r),
|
||||
Region: gw.Region,
|
||||
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
|
||||
return gw.checkPolicyResourcesWithKeys(r, service, action, resources, keys)
|
||||
@@ -588,6 +578,12 @@ func (gw *GatewayConfig) dispatchRegistered(entry *dispatch.Entry, w http.Respon
|
||||
})
|
||||
}
|
||||
|
||||
// requestService is the service the request's credential scope names.
|
||||
func requestService(r *http.Request) string {
|
||||
svc, _ := r.Context().Value(ctxService).(string)
|
||||
return svc
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) GetService(r *http.Request) (string, error) {
|
||||
svc, ok := r.Context().Value(ctxService).(string)
|
||||
if !ok {
|
||||
|
||||
@@ -26,6 +26,11 @@ func withOperator(gw *GatewayConfig) *GatewayConfig {
|
||||
RootCAs: gw.RootCAs,
|
||||
ActiveNodes: gw.DiscoverActiveNodes,
|
||||
Access: gw.BedrockAccess,
|
||||
|
||||
IAM: gw.IAMService,
|
||||
Quota: gw.Quota,
|
||||
ExpectedNodes: gw.ExpectedNodes,
|
||||
NodeIDs: gw.NodeIDs,
|
||||
}
|
||||
// A nil store pointer stored in an interface is not a nil interface, so the
|
||||
// optional capabilities are only set when the gateway holds them.
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func quotaPtr(v int) *int { return &v }
|
||||
|
||||
// quotaIAMService resolves exactly one account.
|
||||
type quotaIAMService struct {
|
||||
policyMockIAMService
|
||||
|
||||
known string
|
||||
}
|
||||
|
||||
func (m *quotaIAMService) GetAccount(accountID string) (*iamdomain.Account, error) {
|
||||
if accountID != m.known {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}, nil
|
||||
}
|
||||
|
||||
// quotaTestGateway wires a real quota Service over a real JetStream KV bucket.
|
||||
func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
|
||||
t.Helper()
|
||||
const account = "000000000042"
|
||||
|
||||
_, _, js := testutil.StartTestJetStream(t)
|
||||
kv, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
|
||||
Bucket: admissionquota.KVBucketAccountQuota,
|
||||
History: 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
quota := admissionquota.New(admissionquota.Limits{
|
||||
Enabled: true, VCPUs: 16, VPCs: 4, Subnets: 16, EIPs: 4,
|
||||
Volumes: 16, VolumesGiB: 200, RDSInstances: 2, LoadBalancers: 2,
|
||||
}, nil)
|
||||
quota.SetOverrides(kv)
|
||||
|
||||
svc := "aIAMService{known: account}
|
||||
return withVerifier(&GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: svc,
|
||||
Quota: quota,
|
||||
}), account
|
||||
}
|
||||
|
||||
// quotaStubChecker answers AccountTerminating from a fixed set, so a test can
|
||||
// seal a record without a real IAM bucket.
|
||||
type quotaStubChecker struct {
|
||||
terminating map[string]bool
|
||||
}
|
||||
|
||||
func (c *quotaStubChecker) AccountTerminating(_ context.Context, accountID string) (bool, error) {
|
||||
return c.terminating[accountID], nil
|
||||
}
|
||||
@@ -117,6 +117,8 @@ type ActorFunc func() (Actor, error)
|
||||
type Invocation struct {
|
||||
Request *http.Request
|
||||
AccountID string
|
||||
// Service is the service the request's SigV4 credential scope names.
|
||||
Service string
|
||||
Region string
|
||||
Authorize AuthorizeFunc
|
||||
Actor ActorFunc
|
||||
|
||||
+24
-24
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -59,14 +59,14 @@ const (
|
||||
adminIdemDone = "done"
|
||||
)
|
||||
|
||||
// adminCreateAccount provisions a tenant account and returns its first
|
||||
// createAccount provisions a tenant account and returns its first
|
||||
// credentials.
|
||||
//
|
||||
// Correctness rests on two CAS creates rather than a transaction: the client
|
||||
// token admits exactly one in-flight attempt, and the name reservation makes
|
||||
// duplicate names impossible. Provisioning itself is resumable, so a failure
|
||||
// anywhere is recovered by retrying with the same token.
|
||||
func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (any, error) {
|
||||
func (d Deps) createAccount(ctx context.Context, body []byte) (any, error) {
|
||||
var req operatorv1.CreateAccountRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
slog.Debug("CreateAccount: malformed JSON body", "err", err)
|
||||
@@ -75,12 +75,12 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
if err := validateCreateAccountRequest(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gw.IAMService == nil {
|
||||
if d.IAM == nil {
|
||||
slog.Error("CreateAccount: IAM service not available")
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
names, idem, err := gw.adminStores(ctx)
|
||||
names, idem, err := d.stores(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
return nil, err
|
||||
}
|
||||
if replay != nil {
|
||||
secret, err := gw.IAMService.DecryptSecret(replay.SecretAccessKey)
|
||||
secret, err := d.IAM.DecryptSecret(replay.SecretAccessKey)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: cannot decrypt replayed secret", "accountID", replay.AccountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -103,7 +103,7 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
response, err := gw.provisionNewAccount(ctx, names, &req)
|
||||
response, err := d.provisionNewAccount(ctx, names, &req)
|
||||
if err != nil {
|
||||
// Drop the token claim so the caller can retry rather than being told
|
||||
// OperationInProgress until the TTL expires.
|
||||
@@ -115,7 +115,7 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
}
|
||||
|
||||
stored := *response
|
||||
ciphertext, err := gw.IAMService.EncryptSecret(response.SecretAccessKey)
|
||||
ciphertext, err := d.IAM.EncryptSecret(response.SecretAccessKey)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to encrypt secret for replay", "accountID", response.AccountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -144,14 +144,14 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
|
||||
// provisionNewAccount reserves the name, creates the account, provisions its
|
||||
// admin identity and waits for the default VPC.
|
||||
func (gw *GatewayConfig) provisionNewAccount(
|
||||
func (d Deps) provisionNewAccount(
|
||||
ctx context.Context,
|
||||
names *iamdomain.AccountNameIndex,
|
||||
req *operatorv1.CreateAccountRequest,
|
||||
) (*operatorv1.CreateAccountResponse, error) {
|
||||
// Accounts that predate the reservation index have no entry to collide
|
||||
// with, so they are found by name scan. The same listing bounds the cap.
|
||||
existing, err := iamdomain.FindAccountByName(gw.IAMService, req.Name)
|
||||
existing, err := iamdomain.FindAccountByName(d.IAM, req.Name)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to list accounts", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -159,7 +159,7 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
if existing != nil {
|
||||
return nil, errors.New(awserrors.ErrorAccountAlreadyExists)
|
||||
}
|
||||
if err := gw.checkAccountCap(); err != nil {
|
||||
if err := d.checkAccountCap(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
account, err := gw.IAMService.CreateAccount(iamdomain.NormalizeAccountName(req.Name))
|
||||
account, err := d.IAM.CreateAccount(iamdomain.NormalizeAccountName(req.Name))
|
||||
if err != nil {
|
||||
if relErr := names.Release(ctx, req.Name, req.ClientToken); relErr != nil {
|
||||
slog.Error("CreateAccount: failed to release name reservation", "err", relErr)
|
||||
@@ -191,14 +191,14 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
provisioned, err := iamdomain.ProvisionAccount(gw.IAMService, account.AccountID, account.AccountName)
|
||||
provisioned, err := iamdomain.ProvisionAccount(d.IAM, account.AccountID, account.AccountName)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to provision account",
|
||||
"accountID", account.AccountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
vpcID, err := gw.ensureDefaultVPC(ctx, account.AccountID)
|
||||
vpcID, err := d.ensureDefaultVPC(ctx, account.AccountID)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: default VPC not ready",
|
||||
"accountID", account.AccountID, "err", err)
|
||||
@@ -268,18 +268,18 @@ func claimClientToken(ctx context.Context, idem jetstream.KeyValue, clientToken,
|
||||
|
||||
// checkAccountCap enforces [signup] max_accounts. Zero or absent means no cap,
|
||||
// which is the pre-existing behaviour for a cluster that never opted in.
|
||||
func (gw *GatewayConfig) checkAccountCap() error {
|
||||
if gw.SignupMaxAccounts <= 0 {
|
||||
func (d Deps) checkAccountCap() error {
|
||||
if d.SignupMaxAccounts <= 0 {
|
||||
return nil
|
||||
}
|
||||
accounts, err := gw.IAMService.ListAccounts()
|
||||
accounts, err := d.IAM.ListAccounts()
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to count accounts for cap", "err", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
if len(accounts) >= gw.SignupMaxAccounts {
|
||||
if len(accounts) >= d.SignupMaxAccounts {
|
||||
slog.Warn("CreateAccount: account cap reached",
|
||||
"accounts", len(accounts), "maxAccounts", gw.SignupMaxAccounts)
|
||||
"accounts", len(accounts), "maxAccounts", d.SignupMaxAccounts)
|
||||
return errors.New(awserrors.ErrorIAMLimitExceeded)
|
||||
}
|
||||
return nil
|
||||
@@ -288,7 +288,7 @@ func (gw *GatewayConfig) checkAccountCap() error {
|
||||
// ensureDefaultVPC asks the daemon to build the account's default VPC and waits
|
||||
// for the acknowledgement, so the response never promises an account that
|
||||
// cannot launch anything.
|
||||
func (gw *GatewayConfig) ensureDefaultVPC(ctx context.Context, accountID string) (string, error) {
|
||||
func (d Deps) ensureDefaultVPC(ctx context.Context, accountID string) (string, error) {
|
||||
payload, err := json.Marshal(struct {
|
||||
AccountID string `json:"account_id"`
|
||||
}{AccountID: accountID})
|
||||
@@ -299,7 +299,7 @@ func (gw *GatewayConfig) ensureDefaultVPC(ctx context.Context, accountID string)
|
||||
reqCtx, cancel := context.WithTimeout(ctx, ensureDefaultVpcTimeout)
|
||||
defer cancel()
|
||||
|
||||
msg, err := gw.NATSConn.RequestWithContext(reqCtx, ec2v1.EnsureDefaultVpcSubject, payload)
|
||||
msg, err := d.NATS.RequestWithContext(reqCtx, ec2v1.EnsureDefaultVpcSubject, payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request default VPC: %w", err)
|
||||
}
|
||||
@@ -320,11 +320,11 @@ func (gw *GatewayConfig) ensureDefaultVPC(ctx context.Context, accountID string)
|
||||
return reply.VpcID, nil
|
||||
}
|
||||
|
||||
// adminStores opens the name index and the idempotency bucket. Both are opened
|
||||
// stores opens the name index and the idempotency bucket. Both are opened
|
||||
// per request rather than cached on the gateway so a bucket created after
|
||||
// startup (a cluster upgraded into this feature) is picked up without a restart.
|
||||
func (gw *GatewayConfig) adminStores(ctx context.Context) (*iamdomain.AccountNameIndex, jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(gw.NATSConn)
|
||||
func (d Deps) stores(ctx context.Context) (*iamdomain.AccountNameIndex, jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(d.NATS)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to get JetStream context", "err", err)
|
||||
return nil, nil, errors.New(awserrors.ErrorServiceUnavailable)
|
||||
+21
-28
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
//test:in-package — exercises the unexported CreateAccount internals
|
||||
// (claimClientToken, checkAccountCap, request validation) directly, since the
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -206,10 +205,8 @@ func TestCheckAccountCap(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: &capMockIAMService{accounts: tc.accounts},
|
||||
Credentials: credverify.New(&capMockIAMService{accounts: tc.accounts}, nil),
|
||||
gw := Deps{
|
||||
IAM: &capMockIAMService{accounts: tc.accounts},
|
||||
SignupMaxAccounts: tc.max,
|
||||
}
|
||||
err := gw.checkAccountCap()
|
||||
@@ -225,10 +222,8 @@ func TestCheckAccountCap(t *testing.T) {
|
||||
|
||||
// A cap that cannot be evaluated must not be treated as "not reached".
|
||||
func TestCheckAccountCapFailsClosedOnListError(t *testing.T) {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: &capMockIAMService{err: assert.AnError},
|
||||
Credentials: credverify.New(&capMockIAMService{err: assert.AnError}, nil),
|
||||
gw := Deps{
|
||||
IAM: &capMockIAMService{err: assert.AnError},
|
||||
SignupMaxAccounts: 128,
|
||||
}
|
||||
|
||||
@@ -239,7 +234,7 @@ func TestCheckAccountCapFailsClosedOnListError(t *testing.T) {
|
||||
|
||||
// newCreateAccountGateway wires a gateway against a real IAM service, embedded
|
||||
// JetStream, and a stub daemon answering the default-VPC request.
|
||||
func newCreateAccountGateway(t *testing.T) *GatewayConfig {
|
||||
func newCreateAccountGateway(t *testing.T) Deps {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
@@ -254,11 +249,9 @@ func newCreateAccountGateway(t *testing.T) *GatewayConfig {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
return &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: svc,
|
||||
Credentials: credverify.New(svc, nil),
|
||||
NATSConn: nc,
|
||||
return Deps{
|
||||
IAM: svc,
|
||||
NATS: nc,
|
||||
SignupMaxAccounts: 128,
|
||||
}
|
||||
}
|
||||
@@ -275,7 +268,7 @@ func TestAdminCreateAccountProvisionsAndReplays(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
body := createAccountBody(t, "ben@example.com", testClientToken)
|
||||
|
||||
first, err := gw.adminCreateAccount(ctx, body)
|
||||
first, err := gw.createAccount(ctx, body)
|
||||
require.NoError(t, err)
|
||||
created := first.(*operatorv1.CreateAccountResponse)
|
||||
|
||||
@@ -287,11 +280,11 @@ func TestAdminCreateAccountProvisionsAndReplays(t *testing.T) {
|
||||
assert.Equal(t, "vpc-0a1b2c3d", created.DefaultVpcID)
|
||||
|
||||
// The replay is the only way to re-obtain the secret, so it must match.
|
||||
second, err := gw.adminCreateAccount(ctx, body)
|
||||
second, err := gw.createAccount(ctx, body)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, created, second.(*operatorv1.CreateAccountResponse))
|
||||
|
||||
accounts, err := gw.IAMService.ListAccounts()
|
||||
accounts, err := gw.IAM.ListAccounts()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, accounts, 1, "replay must not provision a second account")
|
||||
}
|
||||
@@ -300,12 +293,12 @@ func TestAdminCreateAccountRejectsDuplicateName(t *testing.T) {
|
||||
gw := newCreateAccountGateway(t)
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Different token, same name: a second person cannot take an existing name,
|
||||
// and the error discloses nothing about the account that holds it.
|
||||
_, err = gw.adminCreateAccount(ctx, createAccountBody(t, "BEN@example.com", strings.Repeat("b", 32)))
|
||||
_, err = gw.createAccount(ctx, createAccountBody(t, "BEN@example.com", strings.Repeat("b", 32)))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorAccountAlreadyExists, err.Error())
|
||||
}
|
||||
@@ -315,10 +308,10 @@ func TestAdminCreateAccountEnforcesCap(t *testing.T) {
|
||||
gw.SignupMaxAccounts = 1
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = gw.adminCreateAccount(ctx, createAccountBody(t, "second@example.com", strings.Repeat("c", 32)))
|
||||
_, err = gw.createAccount(ctx, createAccountBody(t, "second@example.com", strings.Repeat("c", 32)))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMLimitExceeded, err.Error())
|
||||
}
|
||||
@@ -330,22 +323,22 @@ func TestAdminCreateAccountReleasesTokenAfterFailure(t *testing.T) {
|
||||
gw.SignupMaxAccounts = 1
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := gw.adminCreateAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
_, err := gw.createAccount(ctx, createAccountBody(t, "ben@example.com", testClientToken))
|
||||
require.NoError(t, err)
|
||||
|
||||
capped := createAccountBody(t, "second@example.com", strings.Repeat("c", 32))
|
||||
_, err = gw.adminCreateAccount(ctx, capped)
|
||||
_, err = gw.createAccount(ctx, capped)
|
||||
require.Error(t, err)
|
||||
|
||||
gw.SignupMaxAccounts = 128
|
||||
_, err = gw.adminCreateAccount(ctx, capped)
|
||||
_, err = gw.createAccount(ctx, capped)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAdminCreateAccountMalformedBody(t *testing.T) {
|
||||
gw := newCreateAccountGateway(t)
|
||||
|
||||
_, err := gw.adminCreateAccount(t.Context(), []byte("not json"))
|
||||
_, err := gw.createAccount(t.Context(), []byte("not json"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
|
||||
}
|
||||
@@ -359,7 +352,7 @@ func TestAdminCreateAccountNeverLogsTheSecret(t *testing.T) {
|
||||
t.Cleanup(func() { slog.SetDefault(previous) })
|
||||
|
||||
gw := newCreateAccountGateway(t)
|
||||
out, err := gw.adminCreateAccount(t.Context(), createAccountBody(t, "ben@example.com", testClientToken))
|
||||
out, err := gw.createAccount(t.Context(), createAccountBody(t, "ben@example.com", testClientToken))
|
||||
require.NoError(t, err)
|
||||
|
||||
created := out.(*operatorv1.CreateAccountResponse)
|
||||
+18
-18
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -76,8 +76,8 @@ type accountDeletionJob struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// adminDeleteAccount starts a teardown, or answers a dry run inline.
|
||||
func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (any, error) {
|
||||
// deleteAccount starts a teardown, or answers a dry run inline.
|
||||
func (d Deps) deleteAccount(ctx context.Context, body []byte) (any, error) {
|
||||
var req operatorv1.DeleteAccountRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
slog.Debug("DeleteAccount: malformed JSON body", "err", err)
|
||||
@@ -86,12 +86,12 @@ func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (a
|
||||
if err := validateDeleteAccountRequest(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if gw.IAMService == nil {
|
||||
if d.IAM == nil {
|
||||
slog.Error("DeleteAccount: IAM service not available")
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
engine, err := accountteardown.NewClusterEngine(ctx, gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, gw.IAMService, gw.BucketStore)
|
||||
engine, err := accountteardown.NewClusterEngine(ctx, d.NATS, d.ExpectedNodes, d.NodeIDs, d.IAM, d.BucketStore)
|
||||
if err != nil {
|
||||
slog.Error("DeleteAccount: failed to build teardown engine", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServiceUnavailable)
|
||||
@@ -101,7 +101,7 @@ func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (a
|
||||
inventoryCtx, cancel := context.WithTimeout(ctx, accountDeletionInventoryTimeout)
|
||||
defer cancel()
|
||||
|
||||
inventory, err := engine.DryRun(inventoryCtx, req.AccountID, adminRequestIDFrom(ctx))
|
||||
inventory, err := engine.DryRun(inventoryCtx, req.AccountID, requestIDFrom(ctx))
|
||||
if err != nil {
|
||||
return nil, teardownError("DeleteAccount", req.AccountID, err)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (a
|
||||
}, nil
|
||||
}
|
||||
|
||||
jobs, err := gw.accountDeletionStore(ctx)
|
||||
jobs, err := d.accountDeletionStore(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -141,16 +141,16 @@ func (gw *GatewayConfig) adminDeleteAccount(ctx context.Context, body []byte) (a
|
||||
return deletionJobResponse(replay), nil
|
||||
}
|
||||
|
||||
gw.runAccountDeletion(engine, jobs, job)
|
||||
d.runAccountDeletion(engine, jobs, job)
|
||||
|
||||
return &DeleteAccountResponse{
|
||||
DeletionID: job.DeletionID, AccountID: job.AccountID, State: job.State,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// adminDescribeAccountDeletion returns the stored job for an account, including
|
||||
// describeAccountDeletion returns the stored job for an account, including
|
||||
// one that finished — the record outlives the account it describes.
|
||||
func (gw *GatewayConfig) adminDescribeAccountDeletion(ctx context.Context, body []byte) (any, error) {
|
||||
func (d Deps) describeAccountDeletion(ctx context.Context, body []byte) (any, error) {
|
||||
var req operatorv1.DescribeAccountDeletionRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
slog.Debug("DescribeAccountDeletion: malformed JSON body", "err", err)
|
||||
@@ -164,7 +164,7 @@ func (gw *GatewayConfig) adminDescribeAccountDeletion(ctx context.Context, body
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
jobs, err := gw.accountDeletionStore(ctx)
|
||||
jobs, err := d.accountDeletionStore(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -179,15 +179,15 @@ func (gw *GatewayConfig) adminDescribeAccountDeletion(ctx context.Context, body
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// adminListAccounts enumerates every account. It takes no parameters: the
|
||||
// listAccounts enumerates every account. It takes no parameters: the
|
||||
// account cap bounds the listing, so paging would be complexity with no payer.
|
||||
func (gw *GatewayConfig) adminListAccounts(_ context.Context, _ []byte) (any, error) {
|
||||
if gw.IAMService == nil {
|
||||
func (d Deps) listAccounts(_ context.Context, _ []byte) (any, error) {
|
||||
if d.IAM == nil {
|
||||
slog.Error("ListAccounts: IAM service not available")
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
accounts, err := gw.IAMService.ListAccounts()
|
||||
accounts, err := d.IAM.ListAccounts()
|
||||
if err != nil {
|
||||
slog.Error("ListAccounts: failed to list accounts", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -212,7 +212,7 @@ func (gw *GatewayConfig) adminListAccounts(_ context.Context, _ []byte) (any, er
|
||||
// runAccountDeletion runs the teardown in the background on its own context.
|
||||
// The request's context is cancelled the moment the response is written, and a
|
||||
// teardown cancelled halfway leaves the account half-emptied.
|
||||
func (gw *GatewayConfig) runAccountDeletion(
|
||||
func (d Deps) runAccountDeletion(
|
||||
engine *accountteardown.Engine,
|
||||
jobs jetstream.KeyValue,
|
||||
job *accountDeletionJob,
|
||||
@@ -451,8 +451,8 @@ func readDeletionJob(ctx context.Context, jobs jetstream.KeyValue, accountID str
|
||||
// accountDeletionStore opens the job bucket. Opened per request, like the
|
||||
// CreateAccount stores, so a cluster upgraded into this feature picks it up
|
||||
// without a gateway restart.
|
||||
func (gw *GatewayConfig) accountDeletionStore(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(gw.NATSConn)
|
||||
func (d Deps) accountDeletionStore(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(d.NATS)
|
||||
if err != nil {
|
||||
slog.Error("DeleteAccount: failed to get JetStream context", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServiceUnavailable)
|
||||
+11
-27
@@ -1,18 +1,16 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
//test:in-package — the admin dispatcher's request-ID context and the
|
||||
// deletion handler it reaches are unexported.
|
||||
//test:in-package — the deletion handler and its request-ID context are
|
||||
// unexported.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
accountteardown "github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -20,21 +18,6 @@ import (
|
||||
|
||||
const dryRunShadowMessage = "Account teardown dry-run EC2 platform shadow comparison"
|
||||
|
||||
// deleteAccountGrant is the real IAM service with a policy granting
|
||||
// spinifex:DeleteAccount, so a request clears the admin gates.
|
||||
type deleteAccountGrant struct {
|
||||
iamdomain.IAMService
|
||||
}
|
||||
|
||||
func (deleteAccountGrant) GetUserPolicies(string, string) ([]iamdomain.PolicyDocument, error) {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{{
|
||||
Effect: "Allow", Action: iamdomain.StringOrArr{"spinifex:DeleteAccount"}, Resource: iamdomain.StringOrArr{"*"},
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// syncBuffer is a log sink safe for the gateway's concurrent writers.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
@@ -66,22 +49,23 @@ func captureDefaultLog(t *testing.T) *syncBuffer {
|
||||
// shadow comparison is logged under the request ID the response carries.
|
||||
func TestAdminDeleteAccountDryRunLogsTheShadowUnderTheRequestID(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
gw.IAMService = deleteAccountGrant{IAMService: gw.IAMService}
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
logs := captureDefaultLog(t)
|
||||
|
||||
rec := authorizedAdminRequestForMethod(gw, "DeleteAccount", `{"accountId":"`+account.AccountID+`","dryRun":true}`)
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
requestID := rec.Header().Get("X-Amzn-Requestid")
|
||||
require.NotEmpty(t, requestID)
|
||||
const requestID = "req-dryrun-1"
|
||||
got, err := gw.deleteAccount(WithRequestID(t.Context(), requestID),
|
||||
[]byte(`{"accountId":"`+account.AccountID+`","dryRun":true}`))
|
||||
require.NoError(t, err)
|
||||
gotJSON, err := json.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
|
||||
engine, err := accountteardown.NewClusterEngine(t.Context(), gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, gw.IAMService, gw.BucketStore)
|
||||
engine, err := accountteardown.NewClusterEngine(t.Context(), gw.NATS, gw.ExpectedNodes, gw.NodeIDs, gw.IAM, gw.BucketStore)
|
||||
require.NoError(t, err)
|
||||
inventory, err := engine.Inventory(t.Context(), account.AccountID)
|
||||
require.NoError(t, err)
|
||||
want, err := json.Marshal(&DeleteAccountResponse{AccountID: account.AccountID, State: "DRY_RUN", DryRun: true, Inventory: inventory})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, string(want), rec.Body.String())
|
||||
assert.JSONEq(t, string(want), string(gotJSON))
|
||||
|
||||
var records []map[string]any
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(logs.String()), "\n") {
|
||||
+39
-40
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
//test:in-package — the deletion job record, its claim rules and the progress
|
||||
// tracker are unexported, and they are what make a teardown survive a retry or
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/credverify"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -42,7 +41,7 @@ const sealCapabilitySubjectPrefix = "ec2.v1.AccountSettingsSealCapability."
|
||||
// embedded JetStream, with a stub cluster answering the subjects the reapers
|
||||
// drive. The tenant has no resources, so every stage drains empty and the test
|
||||
// exercises the job record rather than the reapers.
|
||||
func newDeleteAccountGateway(t *testing.T) *GatewayConfig {
|
||||
func newDeleteAccountGateway(t *testing.T) Deps {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
@@ -101,14 +100,12 @@ func newDeleteAccountGateway(t *testing.T) *GatewayConfig {
|
||||
}
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
return &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: svc,
|
||||
Credentials: credverify.New(svc, nil),
|
||||
NATSConn: nc,
|
||||
ExpectedNodes: 1,
|
||||
NodeIDs: []string{"node-1"},
|
||||
BucketStore: emptyBucketStore{},
|
||||
return Deps{
|
||||
IAM: svc,
|
||||
NATS: nc,
|
||||
ExpectedNodes: 1,
|
||||
NodeIDs: []string{"node-1"},
|
||||
BucketStore: emptyBucketStore{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,13 +139,13 @@ func (emptyBucketStore) DeleteBucket(context.Context, string) error { return nil
|
||||
// newTenantAccount creates an account that is safe to delete. Account ids are
|
||||
// sequential and the first belongs to the super admin, which is protected, so
|
||||
// the tenant under test can never be the first one created.
|
||||
func newTenantAccount(t *testing.T, gw *GatewayConfig, name string) *iamdomain.Account {
|
||||
func newTenantAccount(t *testing.T, gw Deps, name string) *iamdomain.Account {
|
||||
t.Helper()
|
||||
if accounts, err := gw.IAMService.ListAccounts(); err == nil && len(accounts) == 0 {
|
||||
_, err := gw.IAMService.CreateAccount("super-admin@example.com")
|
||||
if accounts, err := gw.IAM.ListAccounts(); err == nil && len(accounts) == 0 {
|
||||
_, err := gw.IAM.CreateAccount("super-admin@example.com")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
account, err := gw.IAMService.CreateAccount(name)
|
||||
account, err := gw.IAM.CreateAccount(name)
|
||||
require.NoError(t, err)
|
||||
return account
|
||||
}
|
||||
@@ -164,7 +161,7 @@ func deleteAccountBody(t *testing.T, accountID, name, token string) []byte {
|
||||
|
||||
// storeDeletionJob seeds a job record directly, which is how a job left behind
|
||||
// by another gateway is reproduced without racing a live teardown.
|
||||
func storeDeletionJob(t *testing.T, gw *GatewayConfig, job *accountDeletionJob) jetstream.KeyValue {
|
||||
func storeDeletionJob(t *testing.T, gw Deps, job *accountDeletionJob) jetstream.KeyValue {
|
||||
t.Helper()
|
||||
jobs, err := gw.accountDeletionStore(t.Context())
|
||||
require.NoError(t, err)
|
||||
@@ -176,12 +173,12 @@ func storeDeletionJob(t *testing.T, gw *GatewayConfig, job *accountDeletionJob)
|
||||
return jobs
|
||||
}
|
||||
|
||||
func describeDeletion(t *testing.T, gw *GatewayConfig, accountID string) *accountDeletionJob {
|
||||
func describeDeletion(t *testing.T, gw Deps, accountID string) *accountDeletionJob {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(operatorv1.DescribeAccountDeletionRequest{AccountID: accountID})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := gw.adminDescribeAccountDeletion(t.Context(), body)
|
||||
output, err := gw.describeAccountDeletion(t.Context(), body)
|
||||
require.NoError(t, err)
|
||||
job, ok := output.(*accountDeletionJob)
|
||||
require.True(t, ok)
|
||||
@@ -198,7 +195,7 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
|
||||
body, err := json.Marshal(operatorv1.DeleteAccountRequest{AccountID: account.AccountID, DryRun: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := gw.adminDeleteAccount(t.Context(), body)
|
||||
output, err := gw.deleteAccount(t.Context(), body)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, ok := output.(*DeleteAccountResponse)
|
||||
@@ -208,12 +205,12 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
|
||||
assert.Empty(t, response.DeletionID, "a dry run must not claim a job")
|
||||
require.NotNil(t, response.Inventory)
|
||||
|
||||
stored, err := gw.IAMService.GetAccount(account.AccountID)
|
||||
stored, err := gw.IAM.GetAccount(account.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
|
||||
|
||||
// No job record means a later real delete is not answered as a replay.
|
||||
_, err = gw.adminDescribeAccountDeletion(t.Context(),
|
||||
_, err = gw.describeAccountDeletion(t.Context(),
|
||||
[]byte(`{"accountId":"`+account.AccountID+`"}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIAMNoSuchEntity, err.Error())
|
||||
@@ -227,7 +224,7 @@ func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
|
||||
output, err := gw.adminDeleteAccount(t.Context(),
|
||||
output, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -248,7 +245,7 @@ func TestAdminDeleteAccountReturnsBeforeTheTeardownFinishes(t *testing.T) {
|
||||
assert.Empty(t, job.Error)
|
||||
assert.NotEmpty(t, job.Stages, "the stage report is the only view into a finished teardown")
|
||||
|
||||
_, err = gw.IAMService.GetAccount(account.AccountID)
|
||||
_, err = gw.IAM.GetAccount(account.AccountID)
|
||||
assert.Error(t, err, "the account record must be gone once the job completes")
|
||||
}
|
||||
|
||||
@@ -260,7 +257,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
body := deleteAccountBody(t, account.AccountID, "tenant@example.com", deleteClientToken)
|
||||
|
||||
first, err := gw.adminDeleteAccount(t.Context(), body)
|
||||
first, err := gw.deleteAccount(t.Context(), body)
|
||||
require.NoError(t, err)
|
||||
started := first.(*DeleteAccountResponse)
|
||||
|
||||
@@ -268,7 +265,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
|
||||
return describeDeletion(t, gw, account.AccountID).State == operatorv1.DeletionStateCompleted
|
||||
}, 60*time.Second, 10*time.Millisecond)
|
||||
|
||||
second, err := gw.adminDeleteAccount(t.Context(), body)
|
||||
second, err := gw.deleteAccount(t.Context(), body)
|
||||
require.NoError(t, err)
|
||||
|
||||
replayed, ok := second.(*DeleteAccountResponse)
|
||||
@@ -278,7 +275,7 @@ func TestAdminDeleteAccountReplaysAfterTheAccountIsGone(t *testing.T) {
|
||||
|
||||
// A caller that lost its token and generated a fresh one is told the same
|
||||
// thing, rather than that the account it just deleted does not exist.
|
||||
third, err := gw.adminDeleteAccount(t.Context(),
|
||||
third, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("f", 32)))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, operatorv1.DeletionStateCompleted, third.(*DeleteAccountResponse).State)
|
||||
@@ -298,7 +295,7 @@ func TestAdminDeleteAccountRejectsATokenBoundToAnotherName(t *testing.T) {
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
_, err := gw.adminDeleteAccount(t.Context(),
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "someone-else@example.com", deleteClientToken))
|
||||
|
||||
require.Error(t, err)
|
||||
@@ -319,7 +316,7 @@ func TestAdminDeleteAccountRefusesAConcurrentTeardown(t *testing.T) {
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
_, err := gw.adminDeleteAccount(t.Context(),
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("b", 32)))
|
||||
|
||||
require.Error(t, err)
|
||||
@@ -340,7 +337,7 @@ func TestAdminDeleteAccountTakesOverAStaleJob(t *testing.T) {
|
||||
UpdatedAt: time.Now().UTC().Add(-2 * accountDeletionStale).Format(time.RFC3339),
|
||||
})
|
||||
|
||||
output, err := gw.adminDeleteAccount(t.Context(),
|
||||
output, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("c", 32)))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -365,7 +362,7 @@ func TestAdminDeleteAccountRetriesAFailedJob(t *testing.T) {
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
output, err := gw.adminDeleteAccount(t.Context(),
|
||||
output, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("d", 32)))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -391,7 +388,7 @@ func TestAdminDeleteAccountReportsACompletedJobToANewToken(t *testing.T) {
|
||||
FinishedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
|
||||
output, err := gw.adminDeleteAccount(t.Context(),
|
||||
output, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "tenant@example.com", strings.Repeat("e", 32)))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -410,11 +407,13 @@ func TestAdminDeleteAccountRefusesProtectedAccounts(t *testing.T) {
|
||||
// The system account and the super admin, whichever names they carry.
|
||||
for _, accountID := range []string{"000000000000", "000000000001"} {
|
||||
t.Run(accountID, func(t *testing.T) {
|
||||
_, err := gw.adminDeleteAccount(t.Context(),
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, accountID, "super-admin@example.com", deleteClientToken))
|
||||
|
||||
require.Error(t, err)
|
||||
assertDenied(t, err)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -425,13 +424,13 @@ func TestAdminDeleteAccountRefusesAMismatchedName(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
account := newTenantAccount(t, gw, "tenant@example.com")
|
||||
|
||||
_, err := gw.adminDeleteAccount(t.Context(),
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, account.AccountID, "wrong@example.com", deleteClientToken))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIdempotentParameterMismatch, err.Error())
|
||||
|
||||
stored, err := gw.IAMService.GetAccount(account.AccountID)
|
||||
stored, err := gw.IAM.GetAccount(account.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
|
||||
}
|
||||
@@ -440,7 +439,7 @@ func TestAdminDeleteAccountRejectsAnUnknownAccount(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
newTenantAccount(t, gw, "tenant@example.com")
|
||||
|
||||
_, err := gw.adminDeleteAccount(t.Context(),
|
||||
_, err := gw.deleteAccount(t.Context(),
|
||||
deleteAccountBody(t, "000000009999", "ghost@example.com", deleteClientToken))
|
||||
|
||||
require.Error(t, err)
|
||||
@@ -450,7 +449,7 @@ func TestAdminDeleteAccountRejectsAnUnknownAccount(t *testing.T) {
|
||||
func TestAdminDeleteAccountRejectsAMalformedBody(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
|
||||
_, err := gw.adminDeleteAccount(t.Context(), []byte(`{`))
|
||||
_, err := gw.deleteAccount(t.Context(), []byte(`{`))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
|
||||
@@ -459,9 +458,9 @@ func TestAdminDeleteAccountRejectsAMalformedBody(t *testing.T) {
|
||||
// Without an IAM service there is no way to check what the account owns, and
|
||||
// deleting on a guess is worse than refusing.
|
||||
func TestAdminDeleteAccountRefusesWithoutAnIAMService(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
gw := Deps{}
|
||||
|
||||
_, err := gw.adminDeleteAccount(context.Background(),
|
||||
_, err := gw.deleteAccount(context.Background(),
|
||||
deleteAccountBody(t, "000000000042", "tenant@example.com", deleteClientToken))
|
||||
|
||||
require.Error(t, err)
|
||||
@@ -471,7 +470,7 @@ func TestAdminDeleteAccountRefusesWithoutAnIAMService(t *testing.T) {
|
||||
func TestAdminDescribeAccountDeletionRejectsAMalformedBody(t *testing.T) {
|
||||
gw := newDeleteAccountGateway(t)
|
||||
|
||||
_, err := gw.adminDescribeAccountDeletion(t.Context(), []byte(`{`))
|
||||
_, err := gw.describeAccountDeletion(t.Context(), []byte(`{`))
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
|
||||
+9
-23
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
//test:in-package — the request validators, the job staleness rule and the
|
||||
// engine-error mapping are unexported, and each is a safety rail that would go
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
// listAccountsIAMService answers the one call /admin/ListAccounts makes.
|
||||
type listAccountsIAMService struct {
|
||||
policyMockIAMService
|
||||
iamdomain.IAMService
|
||||
|
||||
accounts []*iamdomain.Account
|
||||
err error
|
||||
@@ -177,7 +177,7 @@ func TestTeardownErrorMapping(t *testing.T) {
|
||||
// The listing is an operator's index of tenants. It must carry the status —
|
||||
// that is how a stuck TERMINATING account is noticed — and no key material.
|
||||
func TestAdminListAccountsReportsStatus(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: &listAccountsIAMService{
|
||||
gw := Deps{IAM: &listAccountsIAMService{
|
||||
accounts: []*iamdomain.Account{
|
||||
{AccountID: "000000000042", AccountName: "t@example.com", Status: iamdomain.AccountStatusActive},
|
||||
nil,
|
||||
@@ -185,7 +185,7 @@ func TestAdminListAccountsReportsStatus(t *testing.T) {
|
||||
},
|
||||
}}
|
||||
|
||||
output, err := gw.adminListAccounts(context.Background(), nil)
|
||||
output, err := gw.listAccounts(context.Background(), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
response, ok := output.(*operatorv1.ListAccountsResponse)
|
||||
@@ -198,40 +198,26 @@ func TestAdminListAccountsReportsStatus(t *testing.T) {
|
||||
// Failing closed matters here: an empty listing reads as "no accounts", which
|
||||
// is exactly what an operator would act on.
|
||||
func TestAdminListAccountsFailsClosed(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: &listAccountsIAMService{
|
||||
gw := Deps{IAM: &listAccountsIAMService{
|
||||
err: errors.New("kv unavailable"),
|
||||
}}
|
||||
|
||||
_, err := gw.adminListAccounts(context.Background(), nil)
|
||||
_, err := gw.listAccounts(context.Background(), nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
}
|
||||
|
||||
// Each admin method is a separate grant. The signup Worker holds
|
||||
// spinifex:CreateAccount and must not reach deletion with it.
|
||||
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: createAccountPolicy()})
|
||||
|
||||
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
rec := authorizedAdminRequestForMethod(gw, method, `{}`)
|
||||
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, decodeAdminError(t, rec).Error.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing account id must be refused before anything reads the cluster, so
|
||||
// DescribeAccountDeletion cannot be used to probe with junk.
|
||||
func TestDescribeAccountDeletionValidatesTheAccountID(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
gw := Deps{}
|
||||
|
||||
_, err := gw.adminDescribeAccountDeletion(context.Background(), []byte(`{}`))
|
||||
_, err := gw.describeAccountDeletion(context.Background(), []byte(`{}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
|
||||
_, err = gw.adminDescribeAccountDeletion(context.Background(), []byte(`{"accountId":"nope"}`))
|
||||
_, err = gw.describeAccountDeletion(context.Background(), []byte(`{"accountId":"nope"}`))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
|
||||
// AccountQuotaRequest addresses one account. PutAccountQuota also carries the
|
||||
@@ -33,15 +34,15 @@ const (
|
||||
sourceOverride = "override"
|
||||
)
|
||||
|
||||
// adminGetAccountQuota reports one account's effective limits, its stored
|
||||
// getAccountQuota reports one account's effective limits, its stored
|
||||
// overrides, and which layer each dimension resolved from.
|
||||
func (gw *GatewayConfig) adminGetAccountQuota(ctx context.Context, body []byte) (any, error) {
|
||||
accountID, _, err := gw.parseAccountQuotaRequest(body)
|
||||
func (d Deps) getAccountQuota(ctx context.Context, body []byte) (any, error) {
|
||||
accountID, _, err := d.parseAccountQuotaRequest(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
over, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
|
||||
over, limits, err := d.Quota.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
@@ -52,19 +53,18 @@ func (gw *GatewayConfig) adminGetAccountQuota(ctx context.Context, body []byte)
|
||||
return accountQuotaResponse(accountID, over, limits), nil
|
||||
}
|
||||
|
||||
// adminPutAccountQuota stores one account's overrides and returns the limits
|
||||
// putAccountQuota stores one account's overrides and returns the limits
|
||||
// they resolve to, so a caller never has to guess what it just applied. An
|
||||
// empty override set clears the record.
|
||||
func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte) (any, error) {
|
||||
accountID, over, err := gw.parseAccountQuotaRequest(body)
|
||||
func (d Deps) putAccountQuota(ctx context.Context, caller dispatch.Actor, body []byte) (any, error) {
|
||||
accountID, over, err := d.parseAccountQuotaRequest(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// The caller's own identity, so a quota change names who made it.
|
||||
caller, _ := callerActor(ctx)
|
||||
updatedBy := actorName(caller)
|
||||
if err := gw.Quota.PutAccountQuota(ctx, accountID, over, updatedBy); err != nil {
|
||||
if err := d.Quota.PutAccountQuota(ctx, accountID, over, updatedBy); err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
}
|
||||
@@ -74,7 +74,7 @@ func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte)
|
||||
slog.InfoContext(ctx, "Account quota override updated",
|
||||
"accountID", accountID, "updatedBy", updatedBy, "cleared", over.Empty())
|
||||
|
||||
stored, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
|
||||
stored, limits, err := d.Quota.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
@@ -88,8 +88,8 @@ func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte)
|
||||
// parseAccountQuotaRequest decodes and validates a quota request. The account
|
||||
// must exist: silently accepting an override for a typo'd ID would store a
|
||||
// record that never applies to anything.
|
||||
func (gw *GatewayConfig) parseAccountQuotaRequest(body []byte) (string, quota.Overrides, error) {
|
||||
if gw.Quota == nil || gw.IAMService == nil {
|
||||
func (d Deps) parseAccountQuotaRequest(body []byte) (string, quota.Overrides, error) {
|
||||
if d.Quota == nil || d.IAM == nil {
|
||||
slog.Error("AccountQuota: quota or IAM service not available")
|
||||
return "", quota.Overrides{}, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func (gw *GatewayConfig) parseAccountQuotaRequest(body []byte) (string, quota.Ov
|
||||
if !accountIDRE.MatchString(req.AccountID) {
|
||||
return "", quota.Overrides{}, errors.New(awserrors.ErrorInvalidRequest)
|
||||
}
|
||||
if _, err := gw.IAMService.GetAccount(req.AccountID); err != nil {
|
||||
if _, err := d.IAM.GetAccount(req.AccountID); err != nil {
|
||||
return "", quota.Overrides{}, errors.New(awserrors.ErrorInvalidRequest)
|
||||
}
|
||||
return req.AccountID, req.Overrides, nil
|
||||
+26
-33
@@ -1,11 +1,11 @@
|
||||
//test:in-package — drives the unexported admin handlers and the request identity they read from the context.
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
@@ -21,7 +21,7 @@ func quotaPtr(v int) *int { return &v }
|
||||
// quotaIAMService resolves exactly one account, so a request naming any other
|
||||
// exercises the "account must exist" gate.
|
||||
type quotaIAMService struct {
|
||||
policyMockIAMService
|
||||
iamdomain.IAMService
|
||||
|
||||
known string
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func (m *quotaIAMService) GetAccount(accountID string) (*iamdomain.Account, erro
|
||||
|
||||
// quotaTestGateway wires a real quota Service over a real JetStream KV bucket,
|
||||
// so the admin handlers are exercised against the store they use in production.
|
||||
func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
|
||||
func quotaTestGateway(t *testing.T) (Deps, string) {
|
||||
t.Helper()
|
||||
const account = "000000000042"
|
||||
|
||||
@@ -52,10 +52,9 @@ func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
|
||||
}, nil)
|
||||
quota.SetOverrides(kv)
|
||||
|
||||
return withVerifier(&GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: "aIAMService{known: account},
|
||||
Quota: quota,
|
||||
return (Deps{
|
||||
IAM: "aIAMService{known: account},
|
||||
Quota: quota,
|
||||
}), account
|
||||
}
|
||||
|
||||
@@ -71,7 +70,7 @@ func quotaRequestBody(t *testing.T, req AccountQuotaRequest) []byte {
|
||||
func TestAdminGetAccountQuotaInherits(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
|
||||
output, err := gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
output, err := gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, ok := output.(*AccountQuotaResponse)
|
||||
@@ -84,9 +83,9 @@ func TestAdminGetAccountQuotaInherits(t *testing.T) {
|
||||
// named changed.
|
||||
func TestAdminPutThenGetAccountQuota(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
|
||||
ctx := t.Context()
|
||||
|
||||
output, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
|
||||
output, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
|
||||
AccountID: account,
|
||||
Overrides: admissionquota.Overrides{VCPUs: quotaPtr(32)},
|
||||
}))
|
||||
@@ -99,7 +98,7 @@ func TestAdminPutThenGetAccountQuota(t *testing.T) {
|
||||
require.Equal(t, "operator", put.Overrides.UpdatedBy)
|
||||
require.Equal(t, 4, put.Limits["vpcs"], "an unnamed dimension must keep inheriting")
|
||||
|
||||
output, err = gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
output, err = gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
require.NoError(t, err)
|
||||
got, ok := output.(*AccountQuotaResponse)
|
||||
require.True(t, ok)
|
||||
@@ -110,15 +109,15 @@ func TestAdminPutThenGetAccountQuota(t *testing.T) {
|
||||
// Clearing must return the account to the configured limits.
|
||||
func TestAdminPutAccountQuotaClears(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
|
||||
_, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
|
||||
AccountID: account,
|
||||
Overrides: admissionquota.Overrides{VCPUs: quotaPtr(32)},
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
output, err := gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, ok := output.(*AccountQuotaResponse)
|
||||
@@ -143,11 +142,11 @@ func TestAdminAccountQuotaRejectsBadRequests(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := gw.adminGetAccountQuota(t.Context(), tt.body)
|
||||
_, err := gw.getAccountQuota(t.Context(), tt.body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
|
||||
|
||||
_, err = gw.adminPutAccountQuota(t.Context(), tt.body)
|
||||
_, err = gw.putAccountQuota(t.Context(), operatorActor, tt.body)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorInvalidRequest, err.Error())
|
||||
})
|
||||
@@ -157,9 +156,9 @@ func TestAdminAccountQuotaRejectsBadRequests(t *testing.T) {
|
||||
// A gateway with quotas disabled has no override bucket; the admin surface must
|
||||
// report that rather than pretend the write landed.
|
||||
func TestAdminAccountQuotaWithoutQuotaService(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, IAMService: "aIAMService{known: "000000000042"}})
|
||||
gw := Deps{IAM: "aIAMService{known: "000000000042"}}
|
||||
|
||||
_, err := gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: "000000000042"}))
|
||||
_, err := gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: "000000000042"}))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
}
|
||||
@@ -198,15 +197,6 @@ func TestAccountQuotaResponseExplicitZeroIsAnOverride(t *testing.T) {
|
||||
require.Equal(t, sourceConfig, resp.Source["vcpus"])
|
||||
}
|
||||
|
||||
// The two quota methods must be routable, or they return InvalidAction on a
|
||||
// cluster that otherwise looks correctly configured.
|
||||
func TestAccountQuotaMethodsAreRegistered(t *testing.T) {
|
||||
require.True(t, adminMethods["GetAccountQuota"])
|
||||
require.True(t, adminMethods["PutAccountQuota"])
|
||||
require.Contains(t, operatorv1.AdminMethodNames(), "GetAccountQuota")
|
||||
require.Contains(t, operatorv1.AdminMethodNames(), "PutAccountQuota")
|
||||
}
|
||||
|
||||
// quotaStubChecker answers AccountTerminating from a fixed set, so these
|
||||
// tests can seal a record without a real IAM bucket.
|
||||
type quotaStubChecker struct {
|
||||
@@ -222,10 +212,10 @@ func (c *quotaStubChecker) AccountTerminating(_ context.Context, accountID strin
|
||||
// type, the stored record stays active, and the response never shows state.
|
||||
func TestAdminRequestCannotCarryState(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
|
||||
ctx := t.Context()
|
||||
|
||||
body := []byte(`{"accountId":"` + account + `","overrides":{"vcpus":32,"state":"sealed"}}`)
|
||||
output, err := gw.adminPutAccountQuota(ctx, body)
|
||||
output, err := gw.putAccountQuota(ctx, operatorActor, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, ok := output.(*AccountQuotaResponse)
|
||||
@@ -248,14 +238,17 @@ func TestAdminQuotaReadsMapBlocked(t *testing.T) {
|
||||
_, err := gw.Quota.SealAccountOverrides(t.Context(), account)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
_, err = gw.getAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorBlocked, err.Error())
|
||||
|
||||
ctx := withPrincipalField(t.Context(), fieldIdentity, "operator")
|
||||
_, err = gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
|
||||
ctx := t.Context()
|
||||
_, err = gw.putAccountQuota(ctx, operatorActor, quotaRequestBody(t, AccountQuotaRequest{
|
||||
AccountID: account, Overrides: admissionquota.Overrides{VCPUs: quotaPtr(8)},
|
||||
}))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorBlocked, err.Error())
|
||||
}
|
||||
|
||||
// operatorActor is the caller a quota change is attributed to.
|
||||
var operatorActor = dispatch.Actor{UserName: "operator", PrincipalType: "user"}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -13,11 +13,14 @@ import (
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
accountteardown "github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -45,6 +48,15 @@ type Deps struct {
|
||||
ModelAccess ModelAccess
|
||||
Credentials ochre.CredentialResolver
|
||||
Access ochre.AccessResolver
|
||||
|
||||
// The /admin methods run against these. A nil IAM or Quota answers the
|
||||
// methods that need it with InternalError.
|
||||
IAM iamdomain.IAMService
|
||||
Quota *quota.Service
|
||||
ExpectedNodes int
|
||||
NodeIDs []string
|
||||
BucketStore accountteardown.BucketStore
|
||||
SignupMaxAccounts int
|
||||
}
|
||||
|
||||
// adminActions are the actions only the super-admin account may call.
|
||||
@@ -79,6 +91,9 @@ func NewRegistration(deps Deps) dispatch.Registration {
|
||||
// resolveAction is the one Query Action parser telemetry and dispatch share.
|
||||
// It leaves the body readable for the dispatcher and answers "" on any failure.
|
||||
func resolveAction(r *http.Request) string {
|
||||
if method, ok := PathMethod(r.URL.Path); ok {
|
||||
return method
|
||||
}
|
||||
args, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -104,6 +119,9 @@ func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
// before the super-admin account gate, and both before an unknown action is
|
||||
// refused, so a denied caller learns nothing about which actions exist.
|
||||
func (d Deps) dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
if isAdminPath(inv.Request.URL.Path) {
|
||||
return d.dispatchREST(w, inv)
|
||||
}
|
||||
r := inv.Request
|
||||
args, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
ingresshttp "github.com/mulgadc/spinifex/spinifex/ingress/http"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const ctxRequestID ctxKey = "admin.requestID"
|
||||
|
||||
// WithRequestID carries the /admin request ID to handlers that log under it.
|
||||
// It correlates log records only and authorises nothing.
|
||||
func WithRequestID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, ctxRequestID, id)
|
||||
}
|
||||
|
||||
func requestIDFrom(ctx context.Context) string {
|
||||
id, _ := ctx.Value(ctxRequestID).(string)
|
||||
return id
|
||||
}
|
||||
|
||||
// actorName is the name the caller goes by: the session name for a role
|
||||
// session and the user name for every other principal.
|
||||
func actorName(a dispatch.Actor) string {
|
||||
if a.PrincipalType == "assumed-role" {
|
||||
return a.SessionName
|
||||
}
|
||||
return a.UserName
|
||||
}
|
||||
|
||||
// RunMethod runs one /admin method against the request body and returns the
|
||||
// value to marshal as the JSON response. caller is the verified caller.
|
||||
func (d Deps) RunMethod(ctx context.Context, method string, caller dispatch.Actor, body []byte) (any, error) {
|
||||
switch method {
|
||||
case "CreateAccount":
|
||||
return d.createAccount(ctx, body)
|
||||
case "DeleteAccount":
|
||||
return d.deleteAccount(ctx, body)
|
||||
case "DescribeAccountDeletion":
|
||||
return d.describeAccountDeletion(ctx, body)
|
||||
case "ListAccounts":
|
||||
return d.listAccounts(ctx, body)
|
||||
case "GetAccountQuota":
|
||||
return d.getAccountQuota(ctx, body)
|
||||
case "PutAccountQuota":
|
||||
return d.putAccountQuota(ctx, caller, body)
|
||||
}
|
||||
// The caller's method set is gated by the contract list, so this is
|
||||
// unreachable; failing closed keeps a name added there without a case from
|
||||
// silently returning 200.
|
||||
return nil, errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// pathPrefix is the private admin surface's URL prefix.
|
||||
const pathPrefix = "/admin/"
|
||||
|
||||
// maxBodyBytes caps the JSON request body. The largest legitimate request is a
|
||||
// few hundred bytes; the cap stops a mistaken caller making the gateway buffer
|
||||
// megabytes.
|
||||
const maxBodyBytes = 8 << 10
|
||||
|
||||
// clusterUnavailableMsg is the 503 body when NATS is disconnected.
|
||||
const clusterUnavailableMsg = "cluster unavailable: NATS disconnected — check daemon /local/status"
|
||||
|
||||
var methods = func() map[string]bool {
|
||||
m := make(map[string]bool)
|
||||
for _, name := range operatorv1.AdminMethodNames() {
|
||||
m[name] = true
|
||||
}
|
||||
return m
|
||||
}()
|
||||
|
||||
// PathMethod extracts the method from an /admin/<Method> path. It reports false
|
||||
// for anything else, including a nested path or a method outside the contract
|
||||
// list, so only the exact shape the router serves is labelled.
|
||||
func PathMethod(path string) (string, bool) {
|
||||
rest, ok := strings.CutPrefix(path, pathPrefix)
|
||||
if !ok || rest == "" || strings.Contains(rest, "/") || !methods[rest] {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// isAdminPath reports whether the path is under the /admin surface, whether or
|
||||
// not the method is a known one.
|
||||
func isAdminPath(path string) bool {
|
||||
return strings.HasPrefix(path, pathPrefix)
|
||||
}
|
||||
|
||||
// dispatchREST serves one /admin/<Method> request. The order is fixed: verb and
|
||||
// method first, then the fail-closed identity gates, then the cluster check
|
||||
// and the body cap. A denial is the same AccessDenied at every gate so a caller
|
||||
// cannot tell which one rejected it.
|
||||
func (d Deps) dispatchREST(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
if r.Method != http.MethodPost {
|
||||
return errors.New(awserrors.ErrorMethodNotAllowed)
|
||||
}
|
||||
method := strings.TrimPrefix(r.URL.Path, pathPrefix)
|
||||
if !methods[method] {
|
||||
slog.DebugContext(r.Context(), "Admin: unknown method", "method", method)
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
caller, _ := inv.Actor()
|
||||
if err := d.authorizeREST(inv, method, caller); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.NATS == nil || !d.NATS.IsConnected() {
|
||||
return awserrors.Errorf(awserrors.ErrorServiceUnavailable, "%s", clusterUnavailableMsg)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxBodyBytes+1))
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "Admin: failed to read request body", "method", method, "err", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
if len(body) > maxBodyBytes {
|
||||
return awserrors.Errorf(awserrors.ErrorInvalidRequest, "request body exceeds 8 KiB")
|
||||
}
|
||||
|
||||
output, err := d.RunMethod(r.Context(), method, caller, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonOutput, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
slog.ErrorContext(r.Context(), "Admin: failed to marshal response", "method", method, "err", err)
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(jsonOutput); err != nil {
|
||||
slog.ErrorContext(r.Context(), "Admin: failed to write response", "method", method, "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// authorizeREST runs the fail-closed gates. Each returns the same AccessDenied;
|
||||
// the log line names the gate for operators.
|
||||
func (d Deps) authorizeREST(inv dispatch.Invocation, method string, caller dispatch.Actor) error {
|
||||
r := inv.Request
|
||||
deny := func(reason string) error {
|
||||
slog.WarnContext(r.Context(), "Admin: access denied", "method", method, "reason", reason,
|
||||
"service", inv.Service, "accountID", caller.AccountID, "identity", actorName(caller),
|
||||
"principalType", caller.PrincipalType, "sourceIP", ingresshttp.ClientIP(r.RemoteAddr))
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
|
||||
// A key signed for another service reaching this path means the caller is
|
||||
// probing, not calling.
|
||||
if inv.Service != ServiceName {
|
||||
return deny("credential scope is not spinifex")
|
||||
}
|
||||
if caller.AccountID != awsidentifiers.DefaultAccountID() {
|
||||
return deny("not the super-admin account")
|
||||
}
|
||||
// Long-lived IAM users only, which excludes assumed-role sessions outright.
|
||||
if caller.PrincipalType != "user" {
|
||||
return deny("principal is not an IAM user")
|
||||
}
|
||||
if actorName(caller) == "" {
|
||||
return deny("no identity in auth context")
|
||||
}
|
||||
return inv.Authorize(ServiceName, method, []string{"*"}, nil)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPathMethod(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"known method", "/admin/CreateAccount", "CreateAccount", true},
|
||||
{"deletion method", "/admin/DeleteAccount", "DeleteAccount", true},
|
||||
{"unknown method", "/admin/PurgeEverything", "", false},
|
||||
{"nested path", "/admin/CreateAccount/extra", "", false},
|
||||
{"bare prefix", "/admin/", "", false},
|
||||
{"not admin", "/", "", false},
|
||||
{"case mismatch", "/admin/createaccount", "", false},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, ok := PathMethod(tc.path)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The credential-minting CLI grants these by name. A method served but absent
|
||||
// from the list would be uncallable by every key an operator issues.
|
||||
func TestPathMethod_AcceptsExactlyTheContractList(t *testing.T) {
|
||||
names := operatorv1.AdminMethodNames()
|
||||
|
||||
require.Len(t, names, len(methods))
|
||||
assert.True(t, sort.StringsAreSorted(names), "names must be sorted for stable help text")
|
||||
for _, name := range names {
|
||||
got, ok := PathMethod(pathPrefix + name)
|
||||
require.True(t, ok, "%s is granted but not routed", name)
|
||||
assert.Equal(t, name, got)
|
||||
}
|
||||
}
|
||||
|
||||
// The registration labels a request with the method its path names, so the
|
||||
// throttle bucket and trace span are per-method rather than one shared bucket.
|
||||
func TestResolveAction_LabelsAdminPathByMethod(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/ListAccounts", strings.NewReader("{}"))
|
||||
|
||||
assert.Equal(t, "ListAccounts", resolveAction(req))
|
||||
}
|
||||
|
||||
func adminActor() dispatch.Actor {
|
||||
return dispatch.Actor{
|
||||
AccountID: awsidentifiers.DefaultAccountID(),
|
||||
PrincipalType: "user",
|
||||
UserName: "signup",
|
||||
}
|
||||
}
|
||||
|
||||
func allowAll(_, _ string, _ []string, _ iampolicy.ConditionKeys) error { return nil }
|
||||
|
||||
func denyAll(_, _ string, _ []string, _ iampolicy.ConditionKeys) error {
|
||||
return awserrors.Errorf(awserrors.ErrorAccessDenied, "denied")
|
||||
}
|
||||
|
||||
// Every gate must deny on its own, and every denial must be indistinguishable
|
||||
// from the others so a caller cannot learn which one it failed.
|
||||
func TestAuthorizeREST_Gates(t *testing.T) {
|
||||
with := func(mod func(*dispatch.Actor)) dispatch.Actor {
|
||||
a := adminActor()
|
||||
mod(&a)
|
||||
return a
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
service string
|
||||
actor dispatch.Actor
|
||||
authorize dispatch.AuthorizeFunc
|
||||
wantErr string
|
||||
}{
|
||||
{"permitted signup principal", ServiceName, adminActor(), allowAll, ""},
|
||||
{"credential scope is not spinifex", "ec2", adminActor(), allowAll, awserrors.ErrorAccessDenied},
|
||||
{"not the super-admin account", ServiceName, with(func(a *dispatch.Actor) { a.AccountID = "123456789012" }), allowAll, awserrors.ErrorAccessDenied},
|
||||
{"assumed-role session", ServiceName, with(func(a *dispatch.Actor) {
|
||||
a.PrincipalType = "assumed-role"
|
||||
a.SessionName = "signup"
|
||||
}), allowAll, awserrors.ErrorAccessDenied},
|
||||
{"no identity", ServiceName, with(func(a *dispatch.Actor) { a.UserName = "" }), allowAll, awserrors.ErrorAccessDenied},
|
||||
{"policy does not grant the method", ServiceName, adminActor(), denyAll, awserrors.ErrorAccessDenied},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/CreateAccount", nil)
|
||||
inv := dispatch.Invocation{Request: req, Service: tc.service, Authorize: tc.authorize}
|
||||
|
||||
err := Deps{}.authorizeREST(inv, "CreateAccount", tc.actor)
|
||||
|
||||
if tc.wantErr == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, tc.wantErr, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The policy check is asked for the spinifex service, the method as the
|
||||
// action and the account-wide resource.
|
||||
func TestAuthorizeREST_AsksPolicyForTheMethod(t *testing.T) {
|
||||
var service, action string
|
||||
var resources []string
|
||||
authorize := func(s, a string, r []string, _ iampolicy.ConditionKeys) error {
|
||||
service, action, resources = s, a, r
|
||||
return nil
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/DeleteAccount", nil)
|
||||
inv := dispatch.Invocation{Request: req, Service: ServiceName, Authorize: authorize}
|
||||
|
||||
require.NoError(t, Deps{}.authorizeREST(inv, "DeleteAccount", adminActor()))
|
||||
|
||||
assert.Equal(t, ServiceName, service)
|
||||
assert.Equal(t, "DeleteAccount", action)
|
||||
assert.Equal(t, []string{"*"}, resources)
|
||||
}
|
||||
|
||||
func restInvocation(method, path, body string, authorize dispatch.AuthorizeFunc, service string) (dispatch.Invocation, *httptest.ResponseRecorder) {
|
||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
return dispatch.Invocation{
|
||||
Request: req,
|
||||
Service: service,
|
||||
Authorize: authorize,
|
||||
Actor: func() (dispatch.Actor, error) { return adminActor(), nil },
|
||||
}, httptest.NewRecorder()
|
||||
}
|
||||
|
||||
// The verb and the method are checked before authorization, so a probe learns
|
||||
// nothing about the caller's permissions from either rejection.
|
||||
func TestDispatchREST_VerbAndMethodBeforeAuthorization(t *testing.T) {
|
||||
var authorized bool
|
||||
authorize := func(_, _ string, _ []string, _ iampolicy.ConditionKeys) error {
|
||||
authorized = true
|
||||
return nil
|
||||
}
|
||||
|
||||
inv, w := restInvocation(http.MethodGet, "/admin/CreateAccount", "", authorize, "ec2")
|
||||
err := Deps{}.dispatchREST(w, inv)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMethodNotAllowed, err.Error())
|
||||
|
||||
inv, w = restInvocation(http.MethodPost, "/admin/PurgeEverything", "{}", authorize, "ec2")
|
||||
err = Deps{}.dispatchREST(w, inv)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
|
||||
|
||||
assert.False(t, authorized, "authorization must not run before the verb and method checks")
|
||||
}
|
||||
|
||||
// A denied caller learns nothing about whether the cluster is up: the policy
|
||||
// check runs before the NATS check.
|
||||
func TestDispatchREST_AuthorizationBeforeClusterCheck(t *testing.T) {
|
||||
inv, w := restInvocation(http.MethodPost, "/admin/CreateAccount", "{}", denyAll, ServiceName)
|
||||
|
||||
err := Deps{}.dispatchREST(w, inv)
|
||||
|
||||
require.Error(t, err)
|
||||
code, _ := awserrors.ResolveErrorCode(err)
|
||||
assert.Equal(t, awserrors.ErrorAccessDenied, code)
|
||||
}
|
||||
|
||||
func TestDispatchREST_AuthorizedWithoutClusterIsUnavailable(t *testing.T) {
|
||||
inv, w := restInvocation(http.MethodPost, "/admin/CreateAccount", "{}", allowAll, ServiceName)
|
||||
|
||||
err := Deps{}.dispatchREST(w, inv)
|
||||
|
||||
require.Error(t, err)
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorServiceUnavailable, code)
|
||||
assert.Equal(t, clusterUnavailableMsg, message)
|
||||
assert.Zero(t, w.Body.Len(), "an error must not also write a success body")
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
operatorv1 "github.com/mulgadc/spinifex/contracts/operator/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Every method in the contract list must have a handler, or the route accepts
|
||||
// the name and then answers InvalidAction on a cluster that looks configured.
|
||||
func TestRunMethod_HandlesEveryContractMethod(t *testing.T) {
|
||||
for _, method := range operatorv1.AdminMethodNames() {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
_, err := Deps{}.RunMethod(t.Context(), method, dispatch.Actor{}, []byte(`{`))
|
||||
|
||||
require.Error(t, err)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.NotEqual(t, awserrors.ErrorInvalidAction, code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunMethod_UnknownMethodFailsClosed(t *testing.T) {
|
||||
_, err := Deps{}.RunMethod(t.Context(), "NoSuchMethod", dispatch.Actor{}, nil)
|
||||
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidAction, err.Error())
|
||||
}
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
accountteardown "github.com/mulgadc/spinifex/spinifex/operator/accountlifecycle"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/admin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -114,8 +114,8 @@ func deleteAccountRemote(
|
||||
ctx context.Context,
|
||||
target adminTarget,
|
||||
req operatorv1.DeleteAccountRequest,
|
||||
) (*gateway.DeleteAccountResponse, error) {
|
||||
var out gateway.DeleteAccountResponse
|
||||
) (*admin.DeleteAccountResponse, error) {
|
||||
var out admin.DeleteAccountResponse
|
||||
if err := callAdmin(ctx, target, "DeleteAccount", req, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/admin"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -92,8 +92,8 @@ func runAccountQuotaGet(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), adminRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out gateway.AccountQuotaResponse
|
||||
req := gateway.AccountQuotaRequest{AccountID: args[0]}
|
||||
var out admin.AccountQuotaResponse
|
||||
req := admin.AccountQuotaRequest{AccountID: args[0]}
|
||||
if err := callAdmin(ctx, target, "GetAccountQuota", req, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,8 +130,8 @@ func runAccountQuotaSet(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), adminRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
var out gateway.AccountQuotaResponse
|
||||
req := gateway.AccountQuotaRequest{AccountID: args[0], Overrides: over}
|
||||
var out admin.AccountQuotaResponse
|
||||
req := admin.AccountQuotaRequest{AccountID: args[0], Overrides: over}
|
||||
if err := callAdmin(ctx, target, "PutAccountQuota", req, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func quotaOverridesFromFlags(cmd *cobra.Command) (quota.Overrides, bool, error)
|
||||
return over, changed, nil
|
||||
}
|
||||
|
||||
func printAccountQuota(resp *gateway.AccountQuotaResponse) {
|
||||
func printAccountQuota(resp *admin.AccountQuotaResponse) {
|
||||
fmt.Printf("Account %s\n\n", resp.AccountID)
|
||||
fmt.Printf("%-16s %10s %s\n", "DIMENSION", "LIMIT", "SOURCE")
|
||||
fmt.Printf("%-16s %10s %s\n", "---------", "-----", "------")
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/operator/admin"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -102,7 +102,7 @@ func TestQuotaOverridesFromFlagsNoneChanged(t *testing.T) {
|
||||
// The table must name every dimension, label its source, and render the
|
||||
// Unlimited sentinel as a word rather than as -1, which reads like a bug.
|
||||
func TestPrintAccountQuota(t *testing.T) {
|
||||
resp := &gateway.AccountQuotaResponse{
|
||||
resp := &admin.AccountQuotaResponse{
|
||||
AccountID: "000000000042",
|
||||
Limits: map[string]int{"vcpus": 32, "vpcs": quota.Unlimited, "eips": 4},
|
||||
Source: map[string]string{"vcpus": "override", "vpcs": "override", "eips": "config"},
|
||||
|
||||
@@ -496,7 +496,6 @@ func launchService(config *config.ClusterConfig) error {
|
||||
RegistryHost: registryHost,
|
||||
AZ: nodeConfig.AZ,
|
||||
IAMService: iamService,
|
||||
BucketStore: objStore,
|
||||
STSService: stsService,
|
||||
Credentials: credverify.New(iamService, stsService),
|
||||
Version: version,
|
||||
@@ -515,7 +514,6 @@ func launchService(config *config.ClusterConfig) error {
|
||||
BedrockProvisioned: bedrockProvisioned,
|
||||
BedrockGuardrails: bedrockGuardrails,
|
||||
BedrockEmbedder: bedrockEmbedder,
|
||||
SignupMaxAccounts: signupMaxAccounts,
|
||||
BedrockAgentKB: bedrockAgentKB,
|
||||
BedrockAgentDataSources: bedrockAgentDataSources,
|
||||
BedrockAgentVector: bedrockAgentVector,
|
||||
@@ -604,6 +602,13 @@ func launchService(config *config.ClusterConfig) error {
|
||||
ModelAccess: bedrockAccess,
|
||||
Credentials: bedrockCredentials,
|
||||
Access: bedrockAccess,
|
||||
|
||||
IAM: iamService,
|
||||
Quota: gw.Quota,
|
||||
ExpectedNodes: len(config.Nodes),
|
||||
NodeIDs: nodeIDs,
|
||||
BucketStore: objStore,
|
||||
SignupMaxAccounts: signupMaxAccounts,
|
||||
})
|
||||
if err := wireServiceRegistry(&gw, ecrControlPlane, acmControlPlane, route53ControlPlane, taggingControlPlane, elbv2ControlPlane, rdsControlPlane, operatorControlPlane); err != nil {
|
||||
return fmt.Errorf("awsgw: %w", err)
|
||||
|
||||
Reference in New Issue
Block a user