From af7cc5a4fba49378ebebbe5f9003d0a51d9db368 Mon Sep 17 00:00:00 2001 From: Sung-Kyu Yoo Date: Sat, 5 Sep 2026 14:00:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20multi-CSP=20groundwork=20=E2=80=94=20IR?= =?UTF-8?q?,=20ModelSource,=20provider=20config,=20auth=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of docs/roadmap.md: make adding a second cloud provider an addition rather than a fork. No non-AWS service ships here, and no AWS behaviour changes — the boto3 suite stays at 775 passing and `make codegen` output is byte-identical. Four seams, each of which used to be an edit and is now an append: codegen — the parser produced a SmithyModel that generators read directly, so the only format DevCloud could read was the one its type was named after. internal/codegen/ir now holds the provider-neutral *ir.Model, and ModelSource reads one format into it. SmithySource is the first implementation and owns its own detection, so cmd/codegen hands each file to SourceFor and never names a format. A file no source claims is an error rather than a quiet skip; the extension filter that used to decide that was format knowledge in the driver. config — service blocks can be written as providers.aws.services.*, forward compatible with providers.azure.*. The top-level services block is the same block under its historical name and is not deprecated; providers.aws.services wins when both are written, and names the loser in a warning. A block for a provider this build cannot serve loads with a warning instead of failing, so a config written for a later DevCloud still starts this one. AWS data dirs stay flat because that layout is a 1.x guarantee; other providers nest under their own name so same-named services cannot collide. plugin — ServicePlugin needed no change; the review is written up in docs/plugin-api.md. The CSP is carried by the optional ProviderScoped interface, read through plugin.ProviderOf. Making it a ServicePlugin method would have forced an edit to all 104 in-tree providers so each could state the value it already defaults to, and would have broken the v1.x contract for no gain. Startup resolves config with cfg.ProviderService(plugin.ProviderOf(p), id). auth — internal/auth holds one Adapter per provider, SigV4 being AWS's. The caller's claimed identity reaches every plugin via auth.FromContext and shows up as a new region field on GET /devcloud/api/logs. Nothing is verified: rejecting unsigned calls would break every SDK pointed at DevCloud with placeholder credentials, which compatibility-policy.md documents as deliberate. The adapter also removes a duplicate parse. protocol.go had its own SigV4 credential-scope reader used for routing; it now delegates, so routing and identity cannot disagree about what the caller signed for. --- changes/unreleased/Added-20260905-135309.yaml | 5 + changes/unreleased/Added-20260905-135310.yaml | 5 + .../unreleased/Changed-20260905-135311.yaml | 5 + cmd/codegen/main.go | 19 ++- cmd/devcloud/main.go | 14 ++- docs/architecture.md | 64 +++++++--- docs/compatibility-policy.md | 7 +- docs/configuration.md | 34 +++++ docs/plugin-api.md | 57 ++++++++- docs/roadmap.md | 18 +-- internal/admin/logger.go | 4 + internal/auth/auth.go | 83 +++++++++++++ internal/auth/sigv4.go | 81 ++++++++++++ internal/auth/sigv4_test.go | 91 ++++++++++++++ internal/codegen/gen_baseprovider.go | 12 +- internal/codegen/gen_crud_meta.go | 12 +- internal/codegen/gen_errors.go | 6 +- internal/codegen/gen_router.go | 8 +- internal/codegen/gen_scaffold.go | 8 +- internal/codegen/gen_types.go | 22 ++-- internal/codegen/gen_types_test.go | 4 +- internal/codegen/generator.go | 6 +- internal/codegen/ir/ir.go | 108 ++++++++++++++++ internal/codegen/model.go | 73 ----------- internal/codegen/parser.go | 53 ++++---- internal/codegen/parser_test.go | 10 +- internal/codegen/source.go | 70 +++++++++++ internal/codegen/source_test.go | 54 ++++++++ internal/config/config.go | 116 ++++++++++++++++-- internal/config/config_test.go | 65 ++++++++++ internal/gateway/gateway.go | 8 ++ internal/gateway/middleware.go | 18 +++ internal/gateway/protocol.go | 33 ++--- internal/plugin/plugin.go | 28 +++++ 34 files changed, 1016 insertions(+), 185 deletions(-) create mode 100644 changes/unreleased/Added-20260905-135309.yaml create mode 100644 changes/unreleased/Added-20260905-135310.yaml create mode 100644 changes/unreleased/Changed-20260905-135311.yaml create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/sigv4.go create mode 100644 internal/auth/sigv4_test.go create mode 100644 internal/codegen/ir/ir.go delete mode 100644 internal/codegen/model.go create mode 100644 internal/codegen/source.go create mode 100644 internal/codegen/source_test.go diff --git a/changes/unreleased/Added-20260905-135309.yaml b/changes/unreleased/Added-20260905-135309.yaml new file mode 100644 index 00000000..eac23366 --- /dev/null +++ b/changes/unreleased/Added-20260905-135309.yaml @@ -0,0 +1,5 @@ +kind: Added +body: 'Provider-namespaced configuration: service blocks can be written as `providers.aws.services.*`, forward-compatible with `providers.azure.*`. The top-level `services` block is the same block under its historical name and keeps working; a block for an unserved provider loads with a warning instead of failing' +time: 2026-09-05T13:53:09.000000+09:00 +custom: + Issue: "135" diff --git a/changes/unreleased/Added-20260905-135310.yaml b/changes/unreleased/Added-20260905-135310.yaml new file mode 100644 index 00000000..51b13e9e --- /dev/null +++ b/changes/unreleased/Added-20260905-135310.yaml @@ -0,0 +1,5 @@ +kind: Added +body: 'Per-provider auth adapters (`internal/auth`): SigV4 credentials are parsed once and the caller''s claimed identity — access key, region, signing name, session token — is available to every plugin via `auth.FromContext`, and reported as a new `region` field on `GET /devcloud/api/logs`. Nothing is verified; DevCloud still accepts any credentials' +time: 2026-09-05T13:53:10.000000+09:00 +custom: + Issue: "135" diff --git a/changes/unreleased/Changed-20260905-135311.yaml b/changes/unreleased/Changed-20260905-135311.yaml new file mode 100644 index 00000000..dbafb7ad --- /dev/null +++ b/changes/unreleased/Changed-20260905-135311.yaml @@ -0,0 +1,5 @@ +kind: Changed +body: 'Codegen now runs through a provider-neutral intermediate representation (`internal/codegen/ir`) behind a `ModelSource` interface, so a second API description format is an added file rather than a rewrite. Smithy is the first source; generated output is byte-identical' +time: 2026-09-05T13:53:11.000000+09:00 +custom: + Issue: "135" diff --git a/cmd/codegen/main.go b/cmd/codegen/main.go index 8fa3bdf5..0d93e9a4 100644 --- a/cmd/codegen/main.go +++ b/cmd/codegen/main.go @@ -52,8 +52,14 @@ func main() { // called incomplete generation clean. skipped := false + // The driver knows nothing about model formats: it hands every file to + // codegen.SourceFor and generates from whatever IR comes back. Adding + // OpenAPI or Protobuf support is a new ModelSource, not a change here. + // A file no source claims is an error rather than a quiet skip — the + // models directory holds models, and a silently ignored one produces + // generated output that is wrong without saying so. for _, entry := range entries { - if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + if entry.IsDir() { continue } @@ -64,9 +70,16 @@ func main() { continue } - model, err := codegen.ParseSmithyJSON(data) + source, err := codegen.SourceFor(entry.Name(), data) if err != nil { - fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", entry.Name(), err) + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + skipped = true + continue + } + + model, err := source.Parse(data) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing %s as %s: %v\n", entry.Name(), source.Name(), err) skipped = true continue } diff --git a/cmd/devcloud/main.go b/cmd/devcloud/main.go index 0719dab7..9af3f088 100644 --- a/cmd/devcloud/main.go +++ b/cmd/devcloud/main.go @@ -62,8 +62,20 @@ func main() { registry := plugin.DefaultRegistry + // A service's provider comes from the plugin itself, so config resolution is + // provider-aware without the config package needing to know which services + // exist. Construct builds an instance without Init, which is all the + // metadata methods need. + providerOf := func(name string) string { + p, ok := registry.Construct(name) + if !ok { + return plugin.DefaultProvider + } + return plugin.ProviderOf(p) + } + initService := func(name string, fatal bool) { - svcCfg := cfg.Service(name) + svcCfg := cfg.ProviderService(providerOf(name), name) if !svcCfg.Enabled { return } diff --git a/docs/architecture.md b/docs/architecture.md index d53a40a5..9e0bc0db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,12 +6,24 @@ DevCloud's long-term direction is to support multiple Cloud Service Providers (A A phased refactor is planned (see [roadmap.md](roadmap.md)): -- **Phase 1 (complete, shipped as v1.0)** — AWS services via Smithy codegen, single-port gateway, AWS SigV4 auth. -- **Phase 2 (current)** — Introduce an Intermediate Representation (IR) between API models and codegen. Abstract `ModelSource` so OpenAPI (Azure) and Protocol Buffers / Discovery Documents (GCP) can feed the same pipeline. Extract a per-provider auth adapter interface. +- **Phase 1 (complete, shipped as v1.0)** — AWS services via Smithy codegen, single-port gateway. +- **Phase 2 (complete)** — Intermediate Representation (IR) between API models and codegen; `ModelSource` so OpenAPI (Azure) and Protocol Buffers / Discovery Documents (GCP) can feed the same pipeline; provider namespacing in config; per-provider auth adapters. - **Phase 3 (pilot)** — First non-AWS service (candidate: Azure Blob Storage) validates the multi-CSP architecture. - **Phase 4 (breadth)** — Additional services across CSPs; community-owned providers. -The existing plugin system, protocol detector, and storage abstractions are intentionally CSP-agnostic where possible, to minimize rework when providers are added. +After Phase 2, the four places a second CSP would have touched are each an +addition rather than an edit: + +| Seam | Package | How a provider joins | +|---|---|---| +| Model → code | [`internal/codegen/ir`](../internal/codegen/ir/ir.go), [`source.go`](../internal/codegen/source.go) | Implement `ModelSource`, append it to `DefaultSources` | +| Configuration | [`internal/config`](../internal/config/config.go) | `providers..services.*`; add the name to `knownProviders` | +| Service contract | [`internal/plugin`](../internal/plugin/plugin.go) | Implement the optional `ProviderScoped` on the plugin | +| Credentials | [`internal/auth`](../internal/auth/auth.go) | Implement `Adapter`, append it to `Adapters` | + +The plugin system, protocol detector, and storage abstractions stay CSP-agnostic +by convention rather than by enforcement — `ProtocolType` is an open string type, +not an enum, for exactly this reason. ## Request Flow @@ -27,6 +39,7 @@ API Gateway (port 4747) │ ├─ CORS (cross-origin handling) │ ├─ RequestID (X-Amz-Request-Id) │ ├─ RequestLogger (structured logging) + │ ├─ Identity (auth.Adapter → claimed identity on the request context) │ └─ LogCollector (admin API live logs) │ ├─ Route: /devcloud/api/* → Admin API @@ -36,13 +49,10 @@ Protocol Detector │ ├─ X-Amz-Target header present → JSON protocol (DynamoDB, SQS JSON) ├─ Content-Type: x-www-form-urlencoded + Action= → Query protocol (IAM, STS, SQS Query) - ├─ SigV4 with Lambda path → REST-JSON (Lambda) + ├─ SigV4 credential scope → REST-JSON (Lambda, and the signing name decides the service) └─ Default → REST-XML (S3) │ ▼ -Auth Validator (SigV4 format check, account ID extraction) - │ - ▼ Service Router → Plugin Registry → ServicePlugin.HandleRequest() │ ▼ @@ -83,9 +93,26 @@ The full contract for each method, the configuration keys, the error convention, SQS supports both Query and JSON protocols. The protocol is auto-detected per request based on Content-Type and headers. -## Smithy Code Generation +## Authentication + +DevCloud reads credentials and never verifies them. `internal/auth` holds one +`Adapter` per provider — `SigV4` for AWS today — which parses the credential +scope off the `Authorization` header or a presigned URL's query string and +reports the access key, region, signing name and session token the caller +claimed. `IdentityMiddleware` puts the result on the request context, where any +plugin can read it with `auth.FromContext`, and the protocol detector uses the +same parse to route by signing name so routing and identity cannot disagree. -DevCloud auto-generates Go code from AWS Smithy JSON models. This enables rapid tracking of AWS API changes with minimal manual work. +Signature validation is deliberately absent: a local server that rejected calls +whose signature did not match would break every SDK pointed at it with +placeholder credentials. See +[compatibility-policy.md](compatibility-policy.md#not-guaranteed). + +## Code Generation + +DevCloud auto-generates Go code from API models. This enables rapid tracking of +API changes with minimal manual work. AWS Smithy is the only format read today; +the pipeline is built so a second one is an added file rather than a rewrite. ### Pipeline @@ -93,8 +120,15 @@ DevCloud auto-generates Go code from AWS Smithy JSON models. This enables rapid smithy-models/*.json (AWS Smithy model files) │ ▼ - Parser (internal/codegen/parser.go) - Reads service definitions, operations, input/output shapes + ModelSource (internal/codegen/source.go) + Each source detects its own format and parses it. SmithySource is + the first; OpenAPI and Protobuf join by implementing the interface + and appending to DefaultSources. cmd/codegen names no format. + │ + ▼ + IR — *ir.Model (internal/codegen/ir) + Provider-neutral: service id, protocol, operations, shapes. + Everything downstream reads only this. │ ▼ Generator (internal/codegen/generator.go) @@ -159,9 +193,11 @@ devcloud/ │ └── codegen/ # Smithy code generator CLI (main.go) ├── internal/ │ ├── gateway/ # HTTP server, middleware, protocol detection, routing -│ ├── plugin/ # ServicePlugin interface, Registry -│ ├── codegen/ # Smithy parser, code generators, templates -│ ├── config/ # YAML config loading, env overrides +│ ├── plugin/ # ServicePlugin interface, Registry, ProviderScoped +│ ├── auth/ # Per-provider credential adapters (SigV4 today) +│ ├── codegen/ # ModelSource implementations, generators, templates +│ │ └── ir/ # Provider-neutral intermediate representation +│ ├── config/ # YAML config loading, provider namespacing, env overrides │ ├── generated/ # Auto-generated code (DO NOT EDIT; run `make stats` for count) │ ├── services/ # Service implementations (run `make stats` for count) │ ├── admin/ # Admin REST API diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md index 64da2967..b5d66abb 100644 --- a/docs/compatibility-policy.md +++ b/docs/compatibility-policy.md @@ -24,6 +24,7 @@ removed or repurposed. Defined in [`internal/config/config.go`](../internal/conf | `services` | map | Presence of the block is authoritative — only the services it lists run, and an empty block runs nothing. Absent means every registered service runs. | | `services..enabled` | bool | Whether that service starts. | | `services..data_dir` | string | Where that service stores data. | +| `providers.aws.services` | map | The same block as `services`, namespaced by provider. Identical semantics; wins over `services` when both are written, with a warning. | | `admin.enabled` | bool | Whether the admin API is served. Default `false`. | | `logging.level` | string | Log level. | | `logging.format` | string | Log format. | @@ -131,8 +132,10 @@ Depending on any of the following will break, and breaking it is **not** a major normalizing it is a minor release, not a major one. - **Log output.** Format, levels and wording of server logs are operational, not an API. - **Everything under `internal/`.** Go forbids importing it from another module, and DevCloud - reserves the right to restructure it freely across 1.x — explicitly including the planned - intermediate representation and `ModelSource` work on the [roadmap](roadmap.md). Internal + reserves the right to restructure it freely across 1.x. The Phase 2 refactor on the + [roadmap](roadmap.md) is the precedent: the intermediate representation, `ModelSource`, + `ProviderScoped` and the `auth` adapters all landed inside a 1.x minor without a + compatibility event, because none of them is reachable from outside this module. Internal churn is not a compatibility event. The in-tree `ServicePlugin` contract in [plugin-api.md](plugin-api.md#api-stability) is not an exception to this: it is a convention that keeps in-tree plugins compiling, and it does not gate release versioning. diff --git a/docs/configuration.md b/docs/configuration.md index 6682f53b..c06f57fd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,6 +45,40 @@ Service-specific options: | `services.lambda.warm_containers` | `0` | Number of warm containers to keep | | `services.iam.enforce_policies` | `false` | Enforce IAM policies (experimental) | +### Provider namespacing + +DevCloud serves AWS today and is being prepared to serve more (see the +[roadmap](roadmap.md)). Service configuration can therefore be written under an +explicit provider: + +```yaml +providers: + aws: + services: + s3: + enabled: true + data_dir: ./data/s3 +``` + +`providers.aws.services` and the top-level `services` block are **the same +block** — one under its forward-compatible name, one under its historical one. +Rules: + +- Write either. The top-level `services` block keeps working exactly as before + and is not deprecated. +- If both are present, `providers.aws.services` wins and a warning names the + ignored block. Do not write both. +- A block for a provider this build does not serve (`providers.azure`) parses + without error — a config written for a later DevCloud still loads — and logs + a warning saying it is ignored. That warning is also your typo check: + `providers.awss` produces it. +- `DEVCLOUD_SERVICES` and `DEVCLOUD_DATA_DIR` are AWS-scoped. There is no + syntax for naming another provider's services, so they cannot silently + disable one. +- AWS data directories stay flat (`/`). Any future provider nests + under its own name (`//`) so two CSPs offering a + same-named service cannot collide. + ### Admin API | Key | Default | Description | diff --git a/docs/plugin-api.md b/docs/plugin-api.md index bb5246db..fad50526 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -38,6 +38,60 @@ type ServicePlugin interface { These invariants are enforced for every registered service by [`TestServicePluginConformance`](../cmd/devcloud/conformance_test.go). +## Providers and CSP neutrality + +Phase 2 of the [roadmap](roadmap.md) reviewed this interface for CSP neutrality. +The conclusion was that **`ServicePlugin` needs no change**: every method is +about serving an HTTP API, and none of them assumes AWS. + +What is AWS-specific is the vocabulary around it, and each piece is open rather +than closed: + +| Surface | Why it is already neutral | +|---|---| +| `ProtocolType` | An open `string` type, not an enum. The constants are AWS's wire protocols; another CSP declares its own values. | +| `ServiceID()` | An opaque registry key. A non-AWS service registers under whatever id it wants. | +| `DefaultAccountID` | An AWS concept used only by AWS services. Nothing in the interface refers to it. | + +The one thing missing was a way to ask *which CSP a plugin serves*. That is the +optional `ProviderScoped` interface: + +```go +type ProviderScoped interface { + Provider() string +} + +func ProviderOf(p ServicePlugin) string // defaults to plugin.DefaultProvider ("aws") +``` + +It is **not** a `ServicePlugin` method on purpose. Adding one would force an +edit to every service in the tree so it could state the value it already +defaults to, and would break the [v1.x contract](#api-stability) below for no +gain. Always read the provider through `ProviderOf`, never by type-asserting. + +Startup uses it to resolve configuration: `cmd/devcloud` looks up each service +with `cfg.ProviderService(plugin.ProviderOf(p), id)`, so a plugin lands in its +own provider's config namespace without the config package needing to know which +services exist. See [configuration.md](configuration.md#provider-namespacing). + +## Caller identity + +The gateway parses whatever credentials a request carried and attaches the +result to the request context. Inside `HandleRequest`: + +```go +if id, ok := auth.FromContext(ctx); ok { + region := id.Region // what the SDK signed for; "" if unsigned +} +``` + +`auth.Identity` carries `Provider`, `AccessKeyID`, `Region`, `Service` (the +signing name as presented) and `SessionToken`. **None of it is verified** — +DevCloud accepts any credentials, so treat these as a claim, never as an +authorization decision. Adapters live in [`internal/auth`](../internal/auth/); +`SigV4` is AWS's, and Azure (AAD/SAS) and GCP (OAuth2) join by implementing +`auth.Adapter`. + ## Protocols `Protocol()` returns the wire protocol the gateway uses to detect and serialize @@ -146,4 +200,5 @@ and the `ProtocolType` constants are stable within the `v1.x` series: governed by [compatibility-policy.md](compatibility-policy.md). New optional capability is introduced additively (new `Options` keys, new -`ProtocolType` values) so existing plugins keep compiling and behaving. +`ProtocolType` values, optional side interfaces such as `ProviderScoped`) so +existing plugins keep compiling and behaving. diff --git a/docs/roadmap.md b/docs/roadmap.md index a209b9d8..005af9ec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -29,17 +29,19 @@ We pursue this vision through a **phased rollout** to manage scope, architectura - [x] Generic CRUD fallback engine ([crud-engine.md](crud-engine.md)) auto-serves ~2,200 CRUD-shaped operations across all 46 JSON-protocol services with plausible, store-backed responses; every registered JSON service is wired. **Follow-up**: promote high-value auto-crud ops to hand-verified fidelity. - [x] v1.0 release — see [compatibility-policy.md](compatibility-policy.md) for what 1.x guarantees -### Phase 2 — Architectural Preparation (Current, v1.x) +### Phase 2 — Architectural Preparation (Complete, v1.x) **Goal**: internal refactor so adding a new CSP doesn't require forking the project. -- [ ] Introduce Intermediate Representation (IR) between API models and codegen -- [ ] Refactor `internal/codegen/parser.go` behind a `ModelSource` interface (Smithy being the first implementation; OpenAPI/Protobuf to follow) -- [ ] Provider namespacing in config (`providers.aws.*`, forward-compatible with `providers.azure.*`) -- [ ] Plugin interface review — ensure `ServicePlugin` is CSP-agnostic -- [ ] Per-provider auth adapter interface (SigV4, AAD/SAS, OAuth2) +- [x] Intermediate Representation (IR) between API models and codegen — [`internal/codegen/ir`](../internal/codegen/ir/ir.go). The generators read `*ir.Model` and nothing in the IR names Smithy; `ir.Model.Provider` carries the owning CSP. +- [x] `internal/codegen/parser.go` refactored behind a [`ModelSource`](../internal/codegen/source.go) interface. `SmithySource` is the first implementation and owns its own format detection, so `cmd/codegen` hands every file to `SourceFor` and never names a format. OpenAPI/Protobuf are an added file. +- [x] Provider namespacing in config — `providers.aws.services.*`, forward-compatible with `providers.azure.*`. The top-level `services` block is the same AWS block under its historical name and keeps working ([configuration.md](configuration.md#provider-namespacing)). +- [x] Plugin interface review — `ServicePlugin` needs no change; the CSP is carried by the optional [`ProviderScoped`](plugin-api.md#providers-and-csp-neutrality) interface, read through `plugin.ProviderOf`. Adding a method would have broken every in-tree plugin to make it state a value it already defaults to. +- [x] Per-provider auth adapter interface — [`internal/auth`](../internal/auth/auth.go). `Adapter` reads one provider's credential form; `SigV4` is the AWS implementation, and AAD/SAS and OAuth2 slot in beside it without touching the gateway. The caller's claimed identity reaches plugins via `auth.FromContext`. -### Phase 3 — First Non-AWS Service (v2.0, exploratory) +What Phase 2 deliberately did **not** do: no non-AWS service, no second `ModelSource`, and no signature verification. Those are Phase 3 and beyond — Phase 2's job was to make each of them an addition rather than a fork. + +### Phase 3 — First Non-AWS Service (Next, v2.0, exploratory) **Goal**: validate the multi-CSP architecture with a single, well-scoped pilot. @@ -76,6 +78,6 @@ We pursue this vision through a **phased rollout** to manage scope, architectura | Version | Focus | |---------|-------| | 0.x | AWS services, unstable API | -| 1.x ← current | AWS depth, stable plugin API | +| 1.x ← current | AWS depth, stable plugin API, multi-CSP groundwork (IR, `ModelSource`, provider namespacing, auth adapters) | | 2.x | Multi-CSP architecture, Azure pilot | | 3.x+ | Broad CSP coverage, community-owned providers | diff --git a/internal/admin/logger.go b/internal/admin/logger.go index 8ffa9622..abcb4691 100644 --- a/internal/admin/logger.go +++ b/internal/admin/logger.go @@ -15,6 +15,10 @@ type RequestLog struct { Duration string `json:"duration"` // e.g. "1.234ms" Timestamp time.Time `json:"timestamp"` Service string `json:"service"` // detected service ID + // Region is the region the caller signed for, read off the request's + // credential scope. Empty when the client did not sign — DevCloud is + // regionless, so this reports what the SDK believed, not where data lives. + Region string `json:"region"` } // LogCollector is a thread-safe ring buffer for recent request logs. diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 00000000..37bdd2bb --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package auth reads the credentials a request carries and reports who the +// caller claimed to be — one adapter per cloud provider. +// +// DevCloud does not verify signatures and no adapter rejects a request. That is +// deliberate and documented: a local development server that refused calls +// because a signature did not match would break every SDK pointed at it with +// placeholder credentials (see docs/compatibility-policy.md, "credentials are +// accepted without signature verification"). An adapter's job is to *read* the +// claim, not to judge it — which is why Authenticate returns an Identity and a +// "did we recognize anything" bool rather than an error. +// +// The interface exists so a second CSP is an added file, not a rewrite: SigV4 +// is AWS's credential form, Azure presents AAD bearer tokens or SAS query +// strings, and Google Cloud presents OAuth2. Each becomes an Adapter in +// Adapters; the gateway does not change. +package auth + +import ( + "context" + "net/http" +) + +// Identity is what a request claimed about its caller. Fields the request did +// not carry are empty, and nothing here has been verified. +type Identity struct { + // Provider is the CSP whose credential form was recognized, e.g. "aws". + Provider string + // AccessKeyID is the key the caller signed with. For AWS it is the first + // element of the SigV4 credential scope. + AccessKeyID string + // Region is the region the caller addressed. DevCloud is regionless, so + // this is what the SDK believed, not where anything lives. + Region string + // Service is the signing name exactly as presented. It is not a DevCloud + // service ID — mapping the two is the gateway's job (normalizeServiceID). + Service string + // SessionToken is the temporary-credential token, when one was sent. + SessionToken string +} + +// Adapter reads one provider's credential presentation off a request. +type Adapter interface { + // Provider is the CSP this adapter speaks for, matching + // plugin.DefaultProvider's vocabulary. + Provider() string + // Authenticate reports the identity the request claims. ok is false when + // the request carries no credential this adapter recognizes; that is the + // normal case for an unsigned client, not an error. + Authenticate(r *http.Request) (id Identity, ok bool) +} + +// Adapters is the ordered set Identify consults. Adding a provider means +// appending its adapter here. +var Adapters = []Adapter{SigV4{}} + +// Identify returns the first identity any adapter recognizes. +func Identify(r *http.Request) (Identity, bool) { + for _, a := range Adapters { + if id, ok := a.Authenticate(r); ok { + return id, true + } + } + return Identity{}, false +} + +// ctxKey is unexported so nothing outside this package can plant an Identity +// that did not come from an adapter. +type ctxKey struct{} + +// NewContext returns a copy of ctx carrying id. +func NewContext(ctx context.Context, id Identity) context.Context { + return context.WithValue(ctx, ctxKey{}, id) +} + +// FromContext returns the identity the gateway attached to the request, if the +// request carried a credential at all. Service plugins can call it from +// HandleRequest to read the caller's claimed region or access key. +func FromContext(ctx context.Context) (Identity, bool) { + id, ok := ctx.Value(ctxKey{}).(Identity) + return id, ok +} diff --git a/internal/auth/sigv4.go b/internal/auth/sigv4.go new file mode 100644 index 00000000..567430f7 --- /dev/null +++ b/internal/auth/sigv4.go @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "net/http" + "strings" + + "github.com/skyoo2003/devcloud/internal/plugin" +) + +// sigV4Algorithm prefixes every SigV4 credential presentation, in both the +// Authorization header and the presigned-URL query form. +const sigV4Algorithm = "AWS4-HMAC-SHA256" + +// credentialParam is the presigned-URL query parameter carrying the credential +// scope. Named here because Authenticate scans the raw query for it before +// paying for a full query parse. +const credentialParam = "X-Amz-Credential" + +const sessionTokenParam = "X-Amz-Security-Token" + +// SigV4 reads AWS Signature Version 4 credentials. It parses the credential +// scope and stops there: the signature is never recomputed or compared. See the +// package comment for why. +type SigV4 struct{} + +func (SigV4) Provider() string { return plugin.DefaultProvider } + +// Authenticate reads the credential scope from the Authorization header, then +// from the presigned-URL query string. Both carry the same scope: +// +// ////aws4_request +// +// A scope shorter than four elements yields an identity with only the access +// key filled in, which is what a malformed or truncated header deserves — +// callers check the field they need rather than trusting ok to mean "complete". +func (s SigV4) Authenticate(r *http.Request) (Identity, bool) { + scope := credentialFromHeader(r.Header.Get("Authorization")) + token := r.Header.Get(sessionTokenParam) + + // Presigned URLs carry no Authorization header. Scanning RawQuery first + // keeps the common signed-header path off url.Values allocation. + if scope == "" && strings.Contains(r.URL.RawQuery, credentialParam) { + q := r.URL.Query() + scope = q.Get(credentialParam) + if token == "" { + token = q.Get(sessionTokenParam) + } + } + if scope == "" { + return Identity{}, false + } + + id := Identity{Provider: s.Provider(), SessionToken: token} + parts := strings.Split(scope, "/") + id.AccessKeyID = parts[0] + if len(parts) >= 4 { + id.Region, id.Service = parts[2], parts[3] + } + return id, true +} + +// credentialFromHeader extracts the credential scope from an Authorization +// header of the form: +// +// AWS4-HMAC-SHA256 Credential=, SignedHeaders=<...>, Signature=<...> +func credentialFromHeader(authz string) string { + if !strings.HasPrefix(authz, sigV4Algorithm) { + return "" + } + idx := strings.Index(authz, "Credential=") + if idx < 0 { + return "" + } + scope := authz[idx+len("Credential="):] + if end := strings.IndexByte(scope, ','); end >= 0 { + scope = scope[:end] + } + return strings.TrimSpace(scope) +} diff --git a/internal/auth/sigv4_test.go b/internal/auth/sigv4_test.go new file mode 100644 index 00000000..49e8f2c9 --- /dev/null +++ b/internal/auth/sigv4_test.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSigV4_AuthorizationHeader(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/", nil) + r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524/eu-west-1/lambda/aws4_request, "+ + "SignedHeaders=host;x-amz-date, Signature=deadbeef") + r.Header.Set("X-Amz-Security-Token", "session-token") + + id, ok := SigV4{}.Authenticate(r) + require.True(t, ok) + assert.Equal(t, "aws", id.Provider) + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", id.AccessKeyID) + assert.Equal(t, "eu-west-1", id.Region) + assert.Equal(t, "lambda", id.Service) + assert.Equal(t, "session-token", id.SessionToken) +} + +// A presigned URL carries no Authorization header; the same scope arrives +// percent-encoded in the query string. +func TestSigV4_PresignedQuery(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, + "/my-bucket/key?X-Amz-Algorithm=AWS4-HMAC-SHA256"+ + "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20130524%2Fus-west-2%2Fs3%2Faws4_request"+ + "&X-Amz-Security-Token=presigned-token", nil) + + id, ok := SigV4{}.Authenticate(r) + require.True(t, ok) + assert.Equal(t, "us-west-2", id.Region) + assert.Equal(t, "s3", id.Service) + assert.Equal(t, "presigned-token", id.SessionToken) +} + +func TestSigV4_Unrecognized(t *testing.T) { + for name, setup := range map[string]func(*http.Request){ + "no credentials": func(*http.Request) {}, + "sigv2 header": func(r *http.Request) { + r.Header.Set("Authorization", "AWS AKIAIOSFODNN7EXAMPLE:signature") + }, + "sigv4 without credential": func(r *http.Request) { + r.Header.Set("Authorization", "AWS4-HMAC-SHA256 SignedHeaders=host, Signature=deadbeef") + }, + } { + t.Run(name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + setup(r) + _, ok := SigV4{}.Authenticate(r) + assert.False(t, ok) + }) + } +} + +// A truncated scope still yields the access key. Callers read the field they +// need, so a partial identity beats discarding the whole header. +func TestSigV4_TruncatedScope(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20130524, Signature=deadbeef") + + id, ok := SigV4{}.Authenticate(r) + require.True(t, ok) + assert.Equal(t, "AKIAIOSFODNN7EXAMPLE", id.AccessKeyID) + assert.Empty(t, id.Region) + assert.Empty(t, id.Service) +} + +func TestIdentifyAndContext(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential=AKIA/20130524/ap-northeast-2/s3/aws4_request, Signature=x") + + id, ok := Identify(r) + require.True(t, ok) + + ctx := NewContext(context.Background(), id) + got, ok := FromContext(ctx) + require.True(t, ok) + assert.Equal(t, "ap-northeast-2", got.Region) + + _, ok = FromContext(context.Background()) + assert.False(t, ok) +} diff --git a/internal/codegen/gen_baseprovider.go b/internal/codegen/gen_baseprovider.go index 2ee1780e..157ce293 100644 --- a/internal/codegen/gen_baseprovider.go +++ b/internal/codegen/gen_baseprovider.go @@ -3,17 +3,21 @@ // internal/codegen/gen_baseprovider.go package codegen -import "strings" +import ( + "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" +) // baseProviderTemplateData is the data model for base_provider.go.tmpl. type baseProviderTemplateData struct { PackageName string - Operations []Operation + Operations []ir.Operation } // GenerateBaseProvider renders the base provider template for the given package -// name and Smithy model, returning a Go source string with NotImplemented stubs. -func (g *Generator) GenerateBaseProvider(pkgName string, model *SmithyModel) (string, error) { +// name and IR model, returning a Go source string with NotImplemented stubs. +func (g *Generator) GenerateBaseProvider(pkgName string, model *ir.Model) (string, error) { data := baseProviderTemplateData{ PackageName: pkgName, Operations: model.Operations, diff --git a/internal/codegen/gen_crud_meta.go b/internal/codegen/gen_crud_meta.go index 775e4064..478058d3 100644 --- a/internal/codegen/gen_crud_meta.go +++ b/internal/codegen/gen_crud_meta.go @@ -6,6 +6,8 @@ import ( "fmt" "go/format" "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) // CRUDRegistryData drives crud_registry.go.tmpl: one file registering every @@ -75,7 +77,7 @@ func singularize(s string) string { // outputKeys finds the output member holding a collection (list key) and the one // wrapping a single resource structure (item key). -func outputKeys(model *SmithyModel, op Operation) (listKey, itemKey string) { +func outputKeys(model *ir.Model, op ir.Operation) (listKey, itemKey string) { out := model.Shapes[op.OutputName] if out == nil { return "", "" @@ -86,11 +88,11 @@ func outputKeys(model *SmithyModel, op Operation) (listKey, itemKey string) { continue } switch target.Type { - case ShapeList: + case ir.ShapeList: if listKey == "" { listKey = mem.Name } - case ShapeStructure: + case ir.ShapeStructure: if itemKey == "" { itemKey = mem.Name } @@ -101,7 +103,7 @@ func outputKeys(model *SmithyModel, op Operation) (listKey, itemKey string) { // classifyOps returns the CRUD metadata for every classifiable operation, in the // model's (already sorted) order for reproducible output. -func classifyOps(model *SmithyModel) []crudOpData { +func classifyOps(model *ir.Model) []crudOpData { var ops []crudOpData for _, op := range model.Operations { verb, resource := canonicalVerb(op.Name) @@ -128,7 +130,7 @@ func isJSONProtocol(protocol string) bool { // ServiceCRUDData classifies a JSON-protocol model's CRUD operations. It returns // (data, false) when the service is not engine-servable or has no CRUD ops. -func ServiceCRUDData(model *SmithyModel) (CRUDServiceData, bool) { +func ServiceCRUDData(model *ir.Model) (CRUDServiceData, bool) { if !isJSONProtocol(model.Protocol) { return CRUDServiceData{}, false } diff --git a/internal/codegen/gen_errors.go b/internal/codegen/gen_errors.go index f03ce423..8dd46539 100644 --- a/internal/codegen/gen_errors.go +++ b/internal/codegen/gen_errors.go @@ -6,6 +6,8 @@ package codegen import ( "sort" "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) // errorData is the template data for a single error shape. @@ -21,9 +23,9 @@ type errorsTemplateData struct { } // GenerateErrors renders the errors template for the given package name and -// Smithy model. It collects all shapes that have an ErrorTrait and passes +// IR model. It collects all shapes that have an ir.ErrorTrait and passes // their names and HTTP status codes to the template. -func (g *Generator) GenerateErrors(pkgName string, model *SmithyModel) (string, error) { +func (g *Generator) GenerateErrors(pkgName string, model *ir.Model) (string, error) { // Collect shape names in sorted order for deterministic output. names := make([]string, 0, len(model.Shapes)) for name := range model.Shapes { diff --git a/internal/codegen/gen_router.go b/internal/codegen/gen_router.go index 3022536e..4e0ad100 100644 --- a/internal/codegen/gen_router.go +++ b/internal/codegen/gen_router.go @@ -3,16 +3,18 @@ // internal/codegen/gen_router.go package codegen +import "github.com/skyoo2003/devcloud/internal/codegen/ir" + // routerTemplateData is the data model for router.go.tmpl. type routerTemplateData struct { PkgName string - Operations []Operation + Operations []ir.Operation } // GenerateRouter renders the router template for the given package name and -// Smithy model, returning a Go source string with route definitions and a URI +// IR model, returning a Go source string with route definitions and a URI // matcher. -func (g *Generator) GenerateRouter(pkgName string, model *SmithyModel) (string, error) { +func (g *Generator) GenerateRouter(pkgName string, model *ir.Model) (string, error) { data := routerTemplateData{ PkgName: pkgName, Operations: model.Operations, diff --git a/internal/codegen/gen_scaffold.go b/internal/codegen/gen_scaffold.go index c8d8f08c..12940958 100644 --- a/internal/codegen/gen_scaffold.go +++ b/internal/codegen/gen_scaffold.go @@ -2,7 +2,11 @@ package codegen -import "strings" +import ( + "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" +) type scaffoldTemplateData struct { PkgName string @@ -34,7 +38,7 @@ func cleanServiceName(name string) string { return strings.TrimSpace(name) } -func (g *Generator) GenerateScaffold(pkgName string, model *SmithyModel) (string, error) { +func (g *Generator) GenerateScaffold(pkgName string, model *ir.Model) (string, error) { data := scaffoldTemplateData{ PkgName: pkgName, ServiceID: model.ServiceID, diff --git a/internal/codegen/gen_types.go b/internal/codegen/gen_types.go index f3db1240..77424728 100644 --- a/internal/codegen/gen_types.go +++ b/internal/codegen/gen_types.go @@ -10,9 +10,13 @@ import ( "sort" "strings" "text/template" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) -// Generator holds the template directory and renders code from Smithy models. +// Generator holds the template directory and renders code from the IR. It is +// format-agnostic: whatever ModelSource produced the model, the generators see +// only *ir.Model. type Generator struct { templateDir string } @@ -81,8 +85,8 @@ type typesTemplateData struct { // resolveGoType converts a GoType string from the model into the appropriate // Go type for use in a generated struct field. Structure references become // pointer types; primitives and others pass through unchanged. -func resolveGoType(goType string, shapes map[string]*Shape) string { - if shape, ok := shapes[goType]; ok && shape.Type == ShapeStructure { +func resolveGoType(goType string, shapes map[string]*ir.Shape) string { + if shape, ok := shapes[goType]; ok && shape.Type == ir.ShapeStructure { return "*" + goType } return goType @@ -106,7 +110,7 @@ func exportName(name string) string { // GenerateTypes produces a Go source file containing struct definitions, // list type aliases, and map type aliases for all shapes in model. -func (g *Generator) GenerateTypes(pkgName string, model *SmithyModel) (string, error) { +func (g *Generator) GenerateTypes(pkgName string, model *ir.Model) (string, error) { data := typesTemplateData{PkgName: pkgName} // Collect shape names in sorted order for deterministic output. @@ -119,7 +123,7 @@ func (g *Generator) GenerateTypes(pkgName string, model *SmithyModel) (string, e for _, name := range names { shape := model.Shapes[name] switch shape.Type { - case ShapeStructure: + case ir.ShapeStructure: // Skip error shapes — they are generated by gen_errors.go if shape.ErrorTrait != nil { continue @@ -136,14 +140,14 @@ func (g *Generator) GenerateTypes(pkgName string, model *SmithyModel) (string, e } data.Structures = append(data.Structures, sd) - case ShapeList: + case ir.ShapeList: if shape.ListMember == nil { continue } elemType := resolveGoType(shape.ListMember.GoType, model.Shapes) data.Lists = append(data.Lists, listData{Name: name, ElemType: elemType}) - case ShapeMap: + case ir.ShapeMap: if shape.KeyMember == nil || shape.ValueMember == nil { continue } @@ -151,10 +155,10 @@ func (g *Generator) GenerateTypes(pkgName string, model *SmithyModel) (string, e valType := resolveGoType(shape.ValueMember.GoType, model.Shapes) data.Maps = append(data.Maps, mapData{Name: name, KeyType: keyType, ValueType: valType}) - case ShapeDocument, ShapeUnion: + case ir.ShapeDocument, ir.ShapeUnion: data.Aliases = append(data.Aliases, aliasData{Name: name, GoType: "interface{}"}) - case ShapeIntEnum: + case ir.ShapeIntEnum: data.Aliases = append(data.Aliases, aliasData{Name: name, GoType: "int32"}) } } diff --git a/internal/codegen/gen_types_test.go b/internal/codegen/gen_types_test.go index 07d8320c..ef5a0282 100644 --- a/internal/codegen/gen_types_test.go +++ b/internal/codegen/gen_types_test.go @@ -8,9 +8,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) -func loadTestModel(t *testing.T) *SmithyModel { +func loadTestModel(t *testing.T) *ir.Model { t.Helper() data, err := os.ReadFile("../../cmd/codegen/testdata/s3-minimal.json") require.NoError(t, err) diff --git a/internal/codegen/generator.go b/internal/codegen/generator.go index 66f57305..b04d222b 100644 --- a/internal/codegen/generator.go +++ b/internal/codegen/generator.go @@ -8,6 +8,8 @@ import ( "go/format" "os" "path/filepath" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) // WriteGo gofmts content and writes it to path. Templates emit close-enough Go; @@ -21,7 +23,7 @@ func WriteGo(path, content string) error { return os.WriteFile(path, formatted, 0644) } -func (g *Generator) GenerateAll(model *SmithyModel, outputDir string, scaffoldDir string) error { +func (g *Generator) GenerateAll(model *ir.Model, outputDir string, scaffoldDir string) error { pkgName := model.ServiceID serviceDir := filepath.Join(outputDir, pkgName) if err := os.MkdirAll(serviceDir, 0755); err != nil { @@ -30,7 +32,7 @@ func (g *Generator) GenerateAll(model *SmithyModel, outputDir string, scaffoldDi generators := []struct { fileName string - generate func(string, *SmithyModel) (string, error) + generate func(string, *ir.Model) (string, error) }{ {"types.go", g.GenerateTypes}, {"base_provider.go", g.GenerateBaseProvider}, diff --git a/internal/codegen/ir/ir.go b/internal/codegen/ir/ir.go new file mode 100644 index 00000000..ad2038fb --- /dev/null +++ b/internal/codegen/ir/ir.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package ir defines the intermediate representation that sits between an API +// description format and the code generator. +// +// Nothing in this package names Smithy, AWS, or any other provider. A +// ModelSource (see internal/codegen) reads one format — Smithy today, OpenAPI +// or Protobuf later — and produces an *ir.Model; the generators read only the +// IR. That is the whole point of the split: adding a second CSP means adding a +// source, not editing every template and generator. +// +// The IR is deliberately close to the shape of an AWS-style API because that is +// the only surface DevCloud serves today. Where a future format needs something +// this type cannot express, add a field — the IR is an internal type with no +// compatibility promise (see docs/compatibility-policy.md). +package ir + +// DefaultProvider is the provider a model belongs to when its source does not +// say. It mirrors plugin.DefaultProvider, which is the runtime half of the same +// convention; the two packages do not import each other, so the constant is +// repeated rather than shared. +const DefaultProvider = "aws" + +// Model is one service's API, normalized away from whatever format described it. +type Model struct { + // Provider is the CSP this service belongs to, e.g. "aws". Empty means + // DefaultProvider. + Provider string + // ServiceName is the human-readable service name, e.g. "AmazonDynamoDB". + ServiceName string + // ServiceID is the stable lowercase identifier used as a package name and + // registry key, e.g. "dynamodb". + ServiceID string + // Protocol is the wire protocol identifier, matching the values of + // plugin.ProtocolType (e.g. "json-1.0", "rest-xml"). + Protocol string + Operations []Operation + Shapes map[string]*Shape +} + +// ProviderID returns the model's provider, defaulting to DefaultProvider. +func (m *Model) ProviderID() string { + if m.Provider == "" { + return DefaultProvider + } + return m.Provider +} + +type Operation struct { + Name string + InputName string + OutputName string + Errors []string + HTTPMethod string + HTTPUri string +} + +type Shape struct { + Name string + Type ShapeType + Members []Member + ListMember *MemberRef + KeyMember *MemberRef + ValueMember *MemberRef + EnumValues []string + ErrorTrait *ErrorTrait +} + +type ShapeType string + +const ( + ShapeStructure ShapeType = "structure" + ShapeList ShapeType = "list" + ShapeMap ShapeType = "map" + ShapeEnum ShapeType = "enum" + ShapeString ShapeType = "string" + ShapeInteger ShapeType = "integer" + ShapeLong ShapeType = "long" + ShapeBoolean ShapeType = "boolean" + ShapeBlob ShapeType = "blob" + ShapeTimestamp ShapeType = "timestamp" + ShapeDouble ShapeType = "double" + ShapeFloat ShapeType = "float" + ShapeUnion ShapeType = "union" + ShapeDocument ShapeType = "document" + ShapeIntEnum ShapeType = "intEnum" +) + +type Member struct { + Name string + TargetName string + GoType string + Required bool + HTTPLabel bool + HTTPHeader string + HTTPQuery string + HTTPPayload bool +} + +type MemberRef struct { + TargetName string + GoType string +} + +type ErrorTrait struct { + Type string // "client" or "server" + HTTPStatus int +} diff --git a/internal/codegen/model.go b/internal/codegen/model.go deleted file mode 100644 index ad132373..00000000 --- a/internal/codegen/model.go +++ /dev/null @@ -1,73 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -// internal/codegen/model.go -package codegen - -type SmithyModel struct { - ServiceName string - ServiceID string - Protocol string - Operations []Operation - Shapes map[string]*Shape -} - -type Operation struct { - Name string - InputName string - OutputName string - Errors []string - HTTPMethod string - HTTPUri string -} - -type Shape struct { - Name string - Type ShapeType - Members []Member - ListMember *MemberRef - KeyMember *MemberRef - ValueMember *MemberRef - EnumValues []string - ErrorTrait *ErrorTrait -} - -type ShapeType string - -const ( - ShapeStructure ShapeType = "structure" - ShapeList ShapeType = "list" - ShapeMap ShapeType = "map" - ShapeEnum ShapeType = "enum" - ShapeString ShapeType = "string" - ShapeInteger ShapeType = "integer" - ShapeLong ShapeType = "long" - ShapeBoolean ShapeType = "boolean" - ShapeBlob ShapeType = "blob" - ShapeTimestamp ShapeType = "timestamp" - ShapeDouble ShapeType = "double" - ShapeFloat ShapeType = "float" - ShapeUnion ShapeType = "union" - ShapeDocument ShapeType = "document" - ShapeIntEnum ShapeType = "intEnum" -) - -type Member struct { - Name string - TargetName string - GoType string - Required bool - HTTPLabel bool - HTTPHeader string - HTTPQuery string - HTTPPayload bool -} - -type MemberRef struct { - TargetName string - GoType string -} - -type ErrorTrait struct { - Type string // "client" or "server" - HTTPStatus int -} diff --git a/internal/codegen/parser.go b/internal/codegen/parser.go index 8ca171f3..4ca69562 100644 --- a/internal/codegen/parser.go +++ b/internal/codegen/parser.go @@ -8,6 +8,8 @@ import ( "fmt" "sort" "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) // Raw JSON types for unmarshaling @@ -56,7 +58,11 @@ type rawHTTPTrait struct { URI string `json:"uri"` } -func ParseSmithyJSON(data []byte) (*SmithyModel, error) { +// ParseSmithyJSON converts a Smithy 2.0 JSON AST into the provider-neutral IR. +// It is the Parse half of SmithySource and is exported because it is the one +// format DevCloud reads today; new formats should be added as a ModelSource +// rather than as another exported parser (see source.go). +func ParseSmithyJSON(data []byte) (*ir.Model, error) { var raw rawModel if err := json.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("unmarshal smithy json: %w", err) @@ -84,11 +90,16 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { return nil, fmt.Errorf("no service shape found") } - model := &SmithyModel{ + model := &ir.Model{ + // Every Smithy model in-tree is an AWS service model: the protocol + // traits this parser recognizes are all under aws.protocols#. A Smithy + // model for another provider would need its own protocol detection, and + // would set Provider itself. + Provider: ir.DefaultProvider, ServiceName: shortName(serviceFQN), ServiceID: detectServiceID(serviceFQN), Protocol: detectProtocol(serviceShape), - Shapes: make(map[string]*Shape), + Shapes: make(map[string]*ir.Shape), } // Parse operations @@ -97,7 +108,7 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { if !ok { continue } - op := Operation{Name: shortName(opFQN)} + op := ir.Operation{Name: shortName(opFQN)} if opShape.Input != nil { if opShape.Input.Target == "smithy.api#Unit" { op.InputName = "SmithyUnit" @@ -137,11 +148,11 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { continue } - shape := &Shape{Name: name, Type: ShapeType(rs.Type)} + shape := &ir.Shape{Name: name, Type: ir.ShapeType(rs.Type)} if rs.Type == "structure" { for memberName, member := range rs.Members { - m := Member{ + m := ir.Member{ Name: memberName, TargetName: shortName(member.Target), GoType: smithyToGoType(member.Target), @@ -176,7 +187,7 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { if errType, ok := rs.Traits["smithy.api#error"]; ok { var et string _ = json.Unmarshal(errType, &et) //nolint:errcheck - shape.ErrorTrait = &ErrorTrait{Type: et} + shape.ErrorTrait = &ir.ErrorTrait{Type: et} if httpErr, ok := rs.Traits["smithy.api#httpError"]; ok { var status int _ = json.Unmarshal(httpErr, &status) //nolint:errcheck @@ -186,7 +197,7 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { } if rs.Type == "list" && rs.Member != nil { - shape.ListMember = &MemberRef{ + shape.ListMember = &ir.MemberRef{ TargetName: shortName(rs.Member.Target), GoType: smithyToGoType(rs.Member.Target), } @@ -194,10 +205,10 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { if rs.Type == "map" { if rs.Key != nil { - shape.KeyMember = &MemberRef{TargetName: shortName(rs.Key.Target), GoType: smithyToGoType(rs.Key.Target)} + shape.KeyMember = &ir.MemberRef{TargetName: shortName(rs.Key.Target), GoType: smithyToGoType(rs.Key.Target)} } if rs.Value != nil { - shape.ValueMember = &MemberRef{TargetName: shortName(rs.Value.Target), GoType: smithyToGoType(rs.Value.Target)} + shape.ValueMember = &ir.MemberRef{TargetName: shortName(rs.Value.Target), GoType: smithyToGoType(rs.Value.Target)} } } @@ -210,7 +221,7 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { for _, op := range model.Operations { if op.OutputName == "SmithyUnit" || op.InputName == "SmithyUnit" { if _, exists := model.Shapes["SmithyUnit"]; !exists { - model.Shapes["SmithyUnit"] = &Shape{Name: "SmithyUnit", Type: ShapeStructure} + model.Shapes["SmithyUnit"] = &ir.Shape{Name: "SmithyUnit", Type: ir.ShapeStructure} } break } @@ -227,30 +238,30 @@ func ParseSmithyJSON(data []byte) (*SmithyModel, error) { // resolveSimpleType walks all structure members and list/map refs, replacing // GoType values that reference simple shapes (string, enum, integer, etc.) // with their underlying Go primitive type. -func resolveSimpleType(shapes map[string]*Shape) { +func resolveSimpleType(shapes map[string]*ir.Shape) { resolve := func(goType string) string { s, ok := shapes[goType] if !ok { return goType } switch s.Type { - case ShapeString, ShapeEnum: + case ir.ShapeString, ir.ShapeEnum: return "string" - case ShapeInteger, ShapeIntEnum: + case ir.ShapeInteger, ir.ShapeIntEnum: return "int32" - case ShapeLong: + case ir.ShapeLong: return "int64" - case ShapeBoolean: + case ir.ShapeBoolean: return "bool" - case ShapeBlob: + case ir.ShapeBlob: return "[]byte" - case ShapeTimestamp: + case ir.ShapeTimestamp: return "time.Time" - case ShapeDouble: + case ir.ShapeDouble: return "float64" - case ShapeFloat: + case ir.ShapeFloat: return "float32" - case ShapeUnion, ShapeDocument: + case ir.ShapeUnion, ir.ShapeDocument: return "interface{}" default: return goType diff --git a/internal/codegen/parser_test.go b/internal/codegen/parser_test.go index 878adb19..43643197 100644 --- a/internal/codegen/parser_test.go +++ b/internal/codegen/parser_test.go @@ -9,6 +9,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" ) func TestParseSmithyJSON(t *testing.T) { @@ -24,7 +26,7 @@ func TestParseSmithyJSON(t *testing.T) { assert.Len(t, model.Operations, 3) // Check CreateBucket operation - var createBucket *Operation + var createBucket *ir.Operation for i := range model.Operations { if model.Operations[i].Name == "CreateBucket" { createBucket = &model.Operations[i] @@ -49,7 +51,7 @@ func TestParseSmithyJSON(t *testing.T) { // Check PutObjectRequest has payload putReq, ok := model.Shapes["PutObjectRequest"] require.True(t, ok) - var bodyMember *Member + var bodyMember *ir.Member for i := range putReq.Members { if putReq.Members[i].Name == "Body" { bodyMember = &putReq.Members[i] @@ -70,7 +72,7 @@ func TestParseSmithyJSON(t *testing.T) { // Check list shape bucketList, ok := model.Shapes["BucketList"] require.True(t, ok) - assert.Equal(t, ShapeList, bucketList.Type) + assert.Equal(t, ir.ShapeList, bucketList.Type) } // resourceModel is a minimal model whose operations hang off resource shapes @@ -127,7 +129,7 @@ func TestParseSmithyJSON_ResourceAttachedOperations(t *testing.T) { }, names, "operations must be collected from resources and sorted") // Output wiring survives the resource walk. - var getWidget *Operation + var getWidget *ir.Operation for i := range model.Operations { if model.Operations[i].Name == "GetWidget" { getWidget = &model.Operations[i] diff --git a/internal/codegen/source.go b/internal/codegen/source.go new file mode 100644 index 00000000..0d3a7b40 --- /dev/null +++ b/internal/codegen/source.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: Apache-2.0 + +package codegen + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + + "github.com/skyoo2003/devcloud/internal/codegen/ir" +) + +// ModelSource reads one API description format and produces the provider-neutral +// IR the generators consume. Smithy is the first implementation; OpenAPI and +// Protobuf are the reason the interface exists. +// +// A source owns its own detection so the codegen driver does not have to know +// which formats exist: it hands each file to SourceFor and generates from +// whatever comes back. +type ModelSource interface { + // Name identifies the format, e.g. "smithy". Used in diagnostics. + Name() string + // Detect reports whether this source can parse data, which was read from + // filename. It must not be expensive — it runs against every candidate file + // for every registered source. + Detect(filename string, data []byte) bool + // Parse converts one model document into the IR. Callers should only call + // Parse on data that Detect accepted. + Parse(data []byte) (*ir.Model, error) +} + +// SmithySource reads the Smithy 2.0 JSON AST that AWS publishes for its +// services. See ParseSmithyJSON for the conversion itself. +type SmithySource struct{} + +func (SmithySource) Name() string { return "smithy" } + +// Detect accepts a .json file whose top-level object carries a "smithy" version +// key. That key is what distinguishes a Smithy AST from any other JSON model +// document — an OpenAPI document in the same directory carries "openapi" +// instead, and is left for the source that claims it. +func (SmithySource) Detect(filename string, data []byte) bool { + if !strings.EqualFold(filepath.Ext(filename), ".json") { + return false + } + var probe struct { + Smithy string `json:"smithy"` + } + return json.Unmarshal(data, &probe) == nil && probe.Smithy != "" +} + +func (SmithySource) Parse(data []byte) (*ir.Model, error) { return ParseSmithyJSON(data) } + +// DefaultSources is the set of formats the codegen driver understands, in +// detection order. Adding a format means appending an implementation here — no +// generator, template or driver change. +var DefaultSources = []ModelSource{SmithySource{}} + +// SourceFor returns the first source in DefaultSources that claims the file. +// A file no source claims is an error at the call site rather than a silent +// skip: an unreadable model used to leave codegen output quietly incomplete. +func SourceFor(filename string, data []byte) (ModelSource, error) { + for _, s := range DefaultSources { + if s.Detect(filename, data) { + return s, nil + } + } + return nil, fmt.Errorf("no model source recognizes %s", filepath.Base(filename)) +} diff --git a/internal/codegen/source_test.go b/internal/codegen/source_test.go new file mode 100644 index 00000000..703a2324 --- /dev/null +++ b/internal/codegen/source_test.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// smithyDoc is a minimal but complete Smithy 2.0 AST: one service binding one +// operation, with a protocol trait so the parser can classify it. +const smithyDoc = `{ + "smithy": "2.0", + "shapes": { + "com.example#Widgets": { + "type": "service", + "version": "2024-01-01", + "operations": [{"target": "com.example#GetWidget"}], + "traits": {"aws.protocols#awsJson1_0": {}} + }, + "com.example#GetWidget": {"type": "operation"} + } +}` + +func TestSmithySourceDetect(t *testing.T) { + s := SmithySource{} + + assert.True(t, s.Detect("widgets.json", []byte(smithyDoc))) + // An OpenAPI document is also JSON. Detection keys off the "smithy" version + // field precisely so the two do not fight over the same file extension. + assert.False(t, s.Detect("widgets.json", []byte(`{"openapi":"3.0.0","paths":{}}`))) + assert.False(t, s.Detect("widgets.yaml", []byte(smithyDoc))) + assert.False(t, s.Detect("widgets.json", []byte("not json at all"))) +} + +func TestSourceForRoutesToSmithy(t *testing.T) { + src, err := SourceFor("widgets.json", []byte(smithyDoc)) + require.NoError(t, err) + assert.Equal(t, "smithy", src.Name()) + + model, err := src.Parse([]byte(smithyDoc)) + require.NoError(t, err) + assert.Equal(t, "aws", model.ProviderID()) + assert.Equal(t, "example", model.ServiceID) + assert.Equal(t, "json-1.0", model.Protocol) +} + +func TestSourceForRejectsUnknownFormat(t *testing.T) { + _, err := SourceFor("widgets.yaml", []byte(smithyDoc)) + require.Error(t, err) + assert.Contains(t, err.Error(), "widgets.yaml") +} diff --git a/internal/config/config.go b/internal/config/config.go index 2402de2d..ceb1ff1d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" @@ -24,12 +25,38 @@ var defaultConfigYAML []byte // the YAML nor DEVCLOUD_DATA_DIR names one. const defaultDataDir = "./data" +// DefaultProvider is the CSP a service belongs to when nothing says otherwise. +// It mirrors plugin.DefaultProvider; config does not import plugin, so the +// constant is repeated rather than shared. +const DefaultProvider = "aws" + +// knownProviders are the providers this build can actually serve. A block for +// anything else still parses — a config written for a later DevCloud loads here +// without erroring — but it is reported, because nothing in this binary will +// read it. Adding a provider means adding its services and this entry in the +// same change. +var knownProviders = map[string]bool{DefaultProvider: true} + type Config struct { Server ServerConfig `yaml:"server"` Services map[string]ServiceConfig `yaml:"services"` Admin *AdminConfig `yaml:"admin"` Logging LoggingConfig `yaml:"logging"` + // Providers namespaces service configuration by CSP: + // + // providers: + // aws: + // services: + // s3: {enabled: true} + // + // The top-level Services block is the AWS provider's block under its + // historical name and keeps working unchanged; providers.aws.services is + // the same block spelled forward-compatibly, and wins when both are set + // (see parse). Blocks for other providers are carried through so a config + // can be written before the services it configures exist. + Providers map[string]ProviderConfig `yaml:"providers"` + // Dashboard is the deprecated pre-rename name for Admin. It is honoured for // one release (see parse) so existing configs keep working; use Admin. // Both Admin and Dashboard are pointers so parse can tell an explicit @@ -58,6 +85,13 @@ type ServiceConfig struct { DataDir string `yaml:"data_dir"` } +// ProviderConfig is one CSP's slice of the config. It holds only a services +// block today; per-provider settings (endpoints, credentials, regions) belong +// here when they arrive. +type ProviderConfig struct { + Services map[string]ServiceConfig `yaml:"services"` +} + type AdminConfig struct { Enabled bool `yaml:"enabled"` } @@ -73,26 +107,41 @@ type LoggingConfig struct { Format string `yaml:"format"` } -// Service returns the effective configuration for serviceID. +// Service returns the effective configuration for an AWS service. It is +// ProviderService for DefaultProvider — the spelling every caller wanted before +// there was more than one provider. +func (c *Config) Service(serviceID string) ServiceConfig { + return c.ProviderService(DefaultProvider, serviceID) +} + +// ProviderService returns the effective configuration for serviceID under +// provider. // // A YAML services block is authoritative: only the services it lists can run. // An empty block therefore runs nothing — it is a block, so it decides. Absent // entirely (the embedded default), every service is enabled with data_dir // /. DEVCLOUD_SERVICES and DEVCLOUD_DATA_DIR are applied here so both // paths agree. -func (c *Config) Service(serviceID string) ServiceConfig { - svc, listed := c.Services[serviceID] +// +// Data directories stay flat for AWS (/) because that layout is a 1.x +// guarantee. Any other provider nests under its own name (//) +// so two CSPs offering a same-named service cannot land on the same directory. +func (c *Config) ProviderService(provider, serviceID string) ServiceConfig { + block, hasBlock := c.serviceBlock(provider) + svc, listed := block[serviceID] switch { - case c.allowed != nil: + case provider == DefaultProvider && c.allowed != nil: // DEVCLOUD_SERVICES names the running set outright, so it decides // membership on its own: it can enable a service the YAML block omits // as well as one the block lists with enabled: false. The block still - // supplies that service's data_dir. Environment beats file. + // supplies that service's data_dir. Environment beats file. It is + // AWS-scoped: there is no syntax for naming another provider's + // services, so it does not silently disable them. svc.Enabled = c.allowed[serviceID] case !listed: - // nil means no block at all; a non-nil empty map means "services: {}", - // which lists nothing and therefore runs nothing. - if c.Services != nil { + // No block at all means every service runs; a block that exists but + // does not list this service means it does not. + if hasBlock { return ServiceConfig{} } svc.Enabled = true @@ -104,10 +153,26 @@ func (c *Config) Service(serviceID string) ServiceConfig { if base == "" { base = defaultDataDir } + if provider != DefaultProvider { + base = filepath.Join(base, provider) + } svc.DataDir = filepath.Join(base, serviceID) return svc } +// serviceBlock returns the services block that governs provider, and whether +// one was written at all. The distinction matters: a block that exists is +// authoritative even when empty, while no block means "run everything". +func (c *Config) serviceBlock(provider string) (map[string]ServiceConfig, bool) { + if pc, ok := c.Providers[provider]; ok && pc.Services != nil { + return pc.Services, true + } + if provider == DefaultProvider && c.Services != nil { + return c.Services, true + } + return nil, false +} + // Load reads and parses a YAML config file from the given path. It returns any // configuration warnings alongside the config so the caller can log them // through the operator-configured handler (see Logging). @@ -169,6 +234,8 @@ func parse(data []byte) (*Config, []string, error) { cfg.Auth = nil } + warnings = append(warnings, checkProviders(cfg)...) + if cfg.Admin == nil { cfg.Admin = &AdminConfig{} } @@ -180,6 +247,39 @@ func parse(data []byte) (*Config, []string, error) { return cfg, append(warnings, applyEnvOverrides(cfg)...), nil } +// checkProviders reports the two ways a providers block can be written and do +// nothing the operator expected: naming a provider this build cannot serve, and +// duplicating the AWS services block that already exists at the top level. +// Neither is an error — both parse and resolve deterministically — but a +// silently ignored services block is exactly the surprise this config package +// warns about elsewhere. +func checkProviders(cfg *Config) []string { + var warnings []string + for _, name := range sortedProviders(cfg.Providers) { + if !knownProviders[name] { + warnings = append(warnings, fmt.Sprintf( + "config: providers.%s is not served by this build and is ignored; check the spelling, or see docs/roadmap.md for which providers exist", name)) + continue + } + if name == DefaultProvider && cfg.Providers[name].Services != nil && cfg.Services != nil { + warnings = append(warnings, fmt.Sprintf( + "config: both 'services' and 'providers.%s.services' are set; the top-level 'services' block is ignored", name)) + } + } + return warnings +} + +// sortedProviders keeps warning order stable across runs — map iteration order +// would otherwise reshuffle the log on every start. +func sortedProviders(providers map[string]ProviderConfig) []string { + names := make([]string, 0, len(providers)) + for name := range providers { + names = append(names, name) + } + sort.Strings(names) + return names +} + var serviceTiers = map[string][]string{ "tier1": { "s3", "sqs", "dynamodb", "iam", "sts", "lambda", diff --git a/internal/config/config_test.go b/internal/config/config_test.go index c1cc2166..d068bcf4 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,6 +72,71 @@ func TestService_EnvServiceFilter(t *testing.T) { assert.False(t, cfg.Service("sqs").Enabled) } +// TestProviderService_AWSBlockIsTheServicesBlock covers the forward-compatible +// spelling: providers.aws.services and the top-level services block are the same +// block, so a config written either way behaves identically. +func TestProviderService_AWSBlockIsTheServicesBlock(t *testing.T) { + isolateEnv(t) + cfg, warnings, err := parse([]byte( + "providers:\n aws:\n services:\n s3:\n enabled: true\n")) + require.NoError(t, err) + assert.Empty(t, warnings) + + assert.True(t, cfg.Service("s3").Enabled) + assert.False(t, cfg.Service("sqs").Enabled, "the provider block is authoritative too") + assert.Equal(t, filepath.Join("./data", "s3"), cfg.Service("s3").DataDir, + "AWS data dirs stay flat — nesting them would break existing installs") +} + +// TestProviderService_ProviderBlockWinsOverLegacy covers the one case where the +// two spellings disagree. Silently picking either would leave half the config +// dead, so the loser is named in a warning. +func TestProviderService_ProviderBlockWinsOverLegacy(t *testing.T) { + isolateEnv(t) + cfg, warnings, err := parse([]byte( + "services:\n sqs:\n enabled: true\n" + + "providers:\n aws:\n services:\n s3:\n enabled: true\n")) + require.NoError(t, err) + + assert.True(t, cfg.Service("s3").Enabled) + assert.False(t, cfg.Service("sqs").Enabled) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "providers.aws.services") +} + +// TestProviderService_UnknownProviderWarnsButLoads is the forward-compatibility +// guarantee: a config written for a later DevCloud must still start this one, +// loudly rather than silently. +func TestProviderService_UnknownProviderWarnsButLoads(t *testing.T) { + isolateEnv(t) + cfg, warnings, err := parse([]byte( + "providers:\n azure:\n services:\n blob:\n enabled: true\n")) + require.NoError(t, err) + require.Len(t, warnings, 1) + assert.Contains(t, warnings[0], "providers.azure") + + // No AWS block was written, so AWS still runs everything. + assert.True(t, cfg.Service("s3").Enabled) + // A non-AWS provider nests its data so a same-named service cannot collide. + assert.Equal(t, filepath.Join("./data", "azure", "blob"), + cfg.ProviderService("azure", "blob").DataDir) +} + +// TestProviderService_EnvFilterIsAWSScoped: DEVCLOUD_SERVICES has no syntax for +// naming another provider's services, so it must not decide their membership. +func TestProviderService_EnvFilterIsAWSScoped(t *testing.T) { + isolateEnv(t) + cfg, _, err := parse([]byte( + "providers:\n azure:\n services:\n blob:\n enabled: true\n")) + require.NoError(t, err) + t.Setenv("DEVCLOUD_SERVICES", "s3") + applyEnvOverrides(cfg) + + assert.False(t, cfg.Service("sqs").Enabled, "the filter still decides AWS") + assert.True(t, cfg.ProviderService("azure", "blob").Enabled, + "an AWS-scoped filter must not disable another provider's service") +} + // TestService_NoServicesBlock_EnablesEverything covers the zero-config // contract: with no services block every service is enabled and lands under // ./data/. diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index cfb506d5..30a533b8 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -10,6 +10,7 @@ import ( "time" "github.com/skyoo2003/devcloud/internal/admin" + "github.com/skyoo2003/devcloud/internal/auth" "github.com/skyoo2003/devcloud/internal/plugin" ) @@ -41,6 +42,9 @@ func New(port int, registry *plugin.Registry, adminAPI http.Handler, logCollecto start := time.Now() rec := newStatusRecorder(w) router.ServeHTTP(rec, r) + // IdentityMiddleware runs before this handler, so the region here is + // what the caller signed for — empty for an unsigned client. + id, _ := auth.FromContext(r.Context()) logCollector.Add(admin.RequestLog{ Method: r.Method, Path: r.URL.Path, @@ -48,16 +52,20 @@ func New(port int, registry *plugin.Registry, adminAPI http.Handler, logCollecto Duration: time.Since(start).String(), Timestamp: start, Service: detectService(r.URL.Path), + Region: id.Region, }) }) } + // IdentityMiddleware is innermost so the identity is on the context before + // anything that reads it — the log collector above, and every plugin. awsHandler := ChainMiddleware(serviceHandler, ErrorRecoveryMiddleware, BodyLimitMiddleware, CORSMiddleware, RequestIDMiddleware, RequestLoggerMiddleware, + IdentityMiddleware, ) // Top-level mux: admin API takes priority; everything else is an AWS API call. diff --git a/internal/gateway/middleware.go b/internal/gateway/middleware.go index b4f6af45..dfc60de4 100644 --- a/internal/gateway/middleware.go +++ b/internal/gateway/middleware.go @@ -10,6 +10,8 @@ import ( "net/http" "strings" "time" + + "github.com/skyoo2003/devcloud/internal/auth" ) // Middleware is a function that wraps an http.Handler to add behaviour. @@ -55,6 +57,22 @@ func RequestLoggerMiddleware(next http.Handler) http.Handler { }) } +// IdentityMiddleware attaches the caller's claimed identity to the request +// context, so any plugin can read the region or access key the SDK signed with +// via auth.FromContext. +// +// It never rejects: an unrecognized or absent credential leaves the context +// untouched and the request proceeds, which is the documented behaviour of a +// server that accepts any credentials (docs/compatibility-policy.md). +func IdentityMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if id, ok := auth.Identify(r); ok { + r = r.WithContext(auth.NewContext(r.Context(), id)) + } + next.ServeHTTP(w, r) + }) +} + // RequestIDMiddleware attaches a unique X-Amz-Request-Id header to every response. func RequestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/gateway/protocol.go b/internal/gateway/protocol.go index 3d6a04ec..e3d0f1ec 100644 --- a/internal/gateway/protocol.go +++ b/internal/gateway/protocol.go @@ -8,6 +8,8 @@ import ( "net/http" "net/url" "strings" + + "github.com/skyoo2003/devcloud/internal/auth" ) // DetectProtocol inspects the incoming HTTP request and returns the AWS protocol @@ -103,31 +105,18 @@ func serviceFromTarget(target string) string { return normalized } -// serviceFromSigV4 extracts the service name from the SigV4 Authorization -// header's credential scope: "AWS4-HMAC-SHA256 Credential=.../region/service/aws4_request" +// serviceFromSigV4 maps the signing name in a request's SigV4 credential scope +// to a DevCloud service ID, or "" when the request carries no scope. +// +// The scope is parsed once, by the auth adapter, which is also what fills the +// request context — routing and identity cannot disagree about what the caller +// signed for. func serviceFromSigV4(r *http.Request) string { - auth := r.Header.Get("Authorization") - if !strings.HasPrefix(auth, "AWS4-HMAC-SHA256") { - return "" - } - // Find "Credential=" part - credIdx := strings.Index(auth, "Credential=") - if credIdx < 0 { + id, ok := auth.SigV4{}.Authenticate(r) + if !ok || id.Service == "" { return "" } - credVal := auth[credIdx+len("Credential="):] - // Credential value ends at comma or end of string - if commaIdx := strings.IndexByte(credVal, ','); commaIdx >= 0 { - credVal = credVal[:commaIdx] - } - // Format: accessKey/date/region/service/aws4_request - parts := strings.Split(credVal, "/") - if len(parts) >= 4 { - svc := parts[3] - // Normalize known service aliases - return normalizeServiceID(svc) - } - return "" + return normalizeServiceID(id.Service) } // normalizeServiceID maps SigV4 signing names, X-Amz-Target prefixes, and diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 017cf051..98725f61 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -14,6 +14,10 @@ import ( // opt in by returning this from their dispatch default case. var ErrUnhandledOp = errors.New("plugin: operation not handled by provider") +// ProtocolType names a wire protocol. It is an open string type on purpose: +// the constants below are AWS's protocols because AWS is what DevCloud serves, +// and a provider for another CSP declares its own values rather than being +// squeezed into these. type ProtocolType string const ( @@ -25,8 +29,32 @@ const ( // DefaultAccountID is the account ID used when no real auth is configured. DefaultAccountID = "000000000000" + + // DefaultProvider is the CSP a plugin belongs to when it does not say. Every + // service that shipped before providers existed is an AWS service, so the + // default is what keeps them correct without touching them. + DefaultProvider = "aws" ) +// ProviderScoped is the optional half of ServicePlugin: a plugin implements it +// to say which CSP it serves. It is deliberately not a ServicePlugin method — +// adding one would force an edit to every service in the tree to state the +// value they already default to, and would break the v1.x interface promise in +// docs/plugin-api.md. Read it through ProviderOf, never by asserting directly. +type ProviderScoped interface { + Provider() string +} + +// ProviderOf reports the CSP a plugin serves, defaulting to DefaultProvider. +func ProviderOf(p ServicePlugin) string { + if ps, ok := p.(ProviderScoped); ok { + if id := ps.Provider(); id != "" { + return id + } + } + return DefaultProvider +} + type PluginConfig struct { DataDir string Options map[string]any