diff --git a/.cargo/audit.toml b/.cargo/audit.toml new file mode 100644 index 0000000..1e2b83f --- /dev/null +++ b/.cargo/audit.toml @@ -0,0 +1,3 @@ +[advisories] +# Marvin Attack: timing side-channel in rsa crate. No fix available upstream. +ignore = ["RUSTSEC-2023-0071"] diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..2d3c45e --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "docs", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["docs:dev"], + "port": 5173, + "cwd": "docs" + } + ] +} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..10d7119 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -e +make ci-fast diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index abd82b9..0f59ef3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -4,18 +4,75 @@ on: push: jobs: + fmt: + name: Format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: rustfmt + - run: cargo fmt --check + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@v2 + - run: cargo clippy -- -D warnings + + check: + name: Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - name: Check workspace + run: cargo check + - name: Check cf-workers (wasm32) + run: cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test + + audit: + name: Audit runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 + with: + toolchain: stable + - uses: Swatinem/rust-cache@v2 + - run: cargo install cargo-audit + - run: cargo audit + build: + name: Build + runs-on: ubuntu-latest steps: - - uses: actions/checkout@d632683dd7b4114ad314bca15554477dd762a938 # v4.2.0 - - name: Set up Rust - uses: actions-rs/toolchain@8e603f32c5c6eeca5b1b2d9d1e7464d926082f1d # v1.0.0 + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@v1 with: toolchain: stable - - name: Format - run: cargo fmt --check - - name: Clippy - run: cargo clippy -- -D warnings - - name: Run tests - run: cargo test + - uses: Swatinem/rust-cache@v2 + - name: Build server + run: cargo build -p source-coop-server diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..519f403 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,64 @@ +name: Deploy Docs + +on: + push: + branches: [main] + paths: + - "docs/**" + - ".github/workflows/docs.yaml" + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: docs/pnpm-lock.yaml + + - uses: actions/configure-pages@v5 + id: pages + + - name: Install dependencies + run: pnpm install --frozen-lockfile + working-directory: docs + + - name: Build docs + run: pnpm docs:build + working-directory: docs + env: + VITEPRESS_BASE: ${{ steps.pages.outputs.base_path && format('{0}/', steps.pages.outputs.base_path) || '/' }} + + - uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/deploy-pages@v4 + id: deployment diff --git a/.github/workflows/please-release.yaml b/.github/workflows/please-release.yaml index 162efa9..72414f3 100644 --- a/.github/workflows/please-release.yaml +++ b/.github/workflows/please-release.yaml @@ -1,8 +1,9 @@ name: Run release-please on: - push: - branches: - - main + workflow_dispatch: + # push: + # branches: + # - main permissions: contents: write diff --git a/.github/workflows/staging-deploy.yaml b/.github/workflows/staging-deploy.yaml index 92b32f3..a023002 100644 --- a/.github/workflows/staging-deploy.yaml +++ b/.github/workflows/staging-deploy.yaml @@ -1,9 +1,9 @@ name: Deploy to Staging on: - push: - branches: - - main + # push: + # branches: + # - main workflow_dispatch: permissions: diff --git a/.gitignore b/.gitignore index 96fa024..abaabb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ -/target .DS_Store scripts/task_definition.json +target +.wrangler +.env* +node_modules +docs/.vitepress/cache +docs/.vitepress/dist diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..cc53e92 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,121 @@ +# Source Data Proxy Architecture + +## Data Proxy + +The core function of this system is to operate an S3-compliant API that proxies requests to appropriate object storage backends (e.g. MinIO, AWS S3, Cloudflare R2, Azure Blobstore). + +## Runtime + +The system is designed to operate in various runtime environments. Chiefly, these includes operating as a traditional server running on a Linux server or containerized environment (e.g. ECS, K8s), or running in WASM on Cloudflare Workers. + +## Authentication + +### How clients authenticate with Source Data Proxy + +The Source Data Proxy supports two forms of authentication: + +1. Custom STS + registered Identity Providers +2. Long-term Access Keys + +#### Custom STS + registered Identity Providers + +The Source Data Proxy hosts a replica of the AWS Security Token Service. This service is used to exchange auth tokens (JWTs) from trusted OIDC-compatible identity providers (e.g. Source Cooperative's auth, Github workflows) for temporary scoped credentials. Those credentials can be used to make authenticated access to the Source Data Proxy. + +For local development and CLI usage, users can obtain temporary credentials via a `credential_process` workflow: + +1. User runs an AWS CLI command (e.g. `aws s3 ls s3://bucket/ --profile source-coop`) +2. The AWS SDK invokes a configured `credential_process` CLI tool +3. The CLI tool authenticates the user with the Source Cooperative's auth provider (e.g. browser-based login) +4. Upon successful login, the CLI tool receives an OIDC JWT from the auth provider +5. The CLI tool calls the Data Proxy's STS endpoint (`AssumeRoleWithWebIdentity`) with the JWT +6. The Data Proxy validates the JWT and returns temporary scoped credentials +7. The CLI tool outputs the credentials to stdout; the AWS SDK uses them transparently + +The user's `~/.aws/config` would look like: + +```ini +[profile source-coop] +credential_process = source credentials # <- source cooperative cli +endpoint_url = https://data.source.coop +``` + +This approach reuses the existing `AssumeRoleWithWebIdentity` STS implementation and avoids the need to implement the full AWS SSO OIDC + Portal API surface (which `aws sso login` requires). + +#### Long-term Access Credentials + +For users that don't have access to OIDC identity providers, the Source Data Proxy can make use of long-term access keys (`AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`). User can generate and retrieve these keys from the Source application (`https://source.coop`). + +### How Source Data Proxy authenticates with object storage backends + +To connect with backing object storage services (e.g. MinIO, AWS S3, Cloudflare R2, Azure Blobstore) + +1. Custom OIDC Provider +2. Long-term Access Keys + +#### Custom OIDC Provider + +The Source Data Proxy operates as a custom OIDC Provider. Users can register this provider with their cloud environments. When the Source Data Proxy needs to connect with an object storage backend, it will generate a JWT signed with the Data Proxy's OIDC provider and use it to retrieve a set of temporary scoped credentials. To reduce latency, these credentials will be cached by the Source Data Proxy for reuse on subsequent requests. This process is akin to how Github or Vercel authenticates with AWS[^vercel-oidc][^github-oidc]. + +The proxy's OIDC discovery endpoints (`/.well-known/openid-configuration` and JWKS) must be publicly accessible, as cloud providers fetch them at token validation time to verify JWT signatures. + +
+ +Cloud Provider Integration Workflows + +##### AWS (S3) + +**Administrator setup:** + +1. Register the proxy's issuer URL (e.g. `https://data.source.coop`) as an IAM OIDC Identity Provider in the AWS account. +2. Create an IAM Role with a trust policy allowing `sts:AssumeRoleWithWebIdentity` from the provider, scoped by `aud` and `sub` claim conditions. +3. Attach a permission policy granting the necessary S3 access. + +**At request time:** + +1. The proxy mints a JWT with `iss: https://data.source.coop`, `sub: `, and `aud: sts.amazonaws.com`. +2. The proxy calls `AssumeRoleWithWebIdentity` on AWS STS with the JWT and the target Role ARN. This call does not require AWS credentials — the JWT is the sole authentication. +3. AWS validates the JWT (fetches JWKS, checks signature, evaluates trust policy conditions) and returns temporary `AccessKeyId` / `SecretAccessKey` / `SessionToken` credentials. +4. The proxy caches and passes these credentials to `AmazonS3Builder`. + +##### Azure (Blob Storage) + +**Administrator setup:** + +1. Create an App Registration (or User-Assigned Managed Identity) in Microsoft Entra ID. +2. Add a Federated Identity Credential specifying the proxy's issuer URL and the expected `sub` claim value. +3. Grant the app registration a role assignment on the target storage account (e.g. `Storage Blob Data Contributor`). + +**At request time:** + +1. The proxy mints a JWT with `iss: https://data.source.coop`, `sub: `, and `aud: api://AzureADTokenExchange`. +2. The proxy exchanges the JWT for an Azure AD access token via the Microsoft identity platform token endpoint using `grant_type=client_credentials` with `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`. The JWT replaces a client secret. +3. Azure validates the JWT against the federated identity credential configuration and returns an OAuth 2.0 bearer token scoped to Azure Storage. +4. The proxy caches and passes the bearer token to `MicrosoftAzureBuilder`. + +##### GCP (Cloud Storage) + +**Administrator setup:** + +1. Create a Workload Identity Pool and an OIDC Provider within it, specifying the proxy's issuer URL and an attribute mapping (e.g. `google.subject = assertion.sub`). +2. Grant the mapped external identity `roles/iam.workloadIdentityUser` on a GCP Service Account. +3. Grant the service account the necessary GCS permissions. + +**At request time (two-step exchange):** + +1. The proxy mints a JWT with `iss: https://data.source.coop`, `sub: `, and `aud` set to the Workload Identity Provider's full resource name. +2. The proxy calls the GCP STS endpoint (`sts.googleapis.com/v1/token`) with an RFC 8693 token exchange request, submitting the JWT as the subject token. GCP returns a federated access token. +3. The proxy uses the federated token to call the IAM Credentials API (`generateAccessToken`) to impersonate the service account, obtaining a short-lived OAuth 2.0 access token. +4. The proxy caches and passes the access token to `GoogleCloudStorageBuilder` via a custom `CredentialProvider`. + +
+ +#### Long-term Access Credentials + +For object storage backends that are unable to utilize the Source Data Proxy as an Identity Provider, the Data Proxy also stores long-term access credentials provided by the administrators of the object storage backend. These credentials will be used to authenticate when the Data Proxy needs to interact with the object storage backend. + +[^vercel-oidc]: https://vercel.com/docs/oidc/aws +[^github-oidc]: https://docs.github.com/en/actions/concepts/security/openid-connect + +## Modularity + +The primary focus of this codebase is to serve as a data proxy for the [Source Cooperative](https://source.coop). However, it is built in a modular fashion to support reuse by others who have similar needs. \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b8b75b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# S3 Proxy Gateway + +Multi-runtime S3 gateway proxy in Rust. Each runtime have feature parity. Proxies S3-compatible API requests to backend object stores with authentication, authorization, and streaming passthrough. Uses presigned URLs via `object_store`'s `Signer` trait for GET/HEAD/PUT/DELETE (enabling zero-copy streaming), `object_store` directly for LIST, and raw signed HTTP for multipart uploads. + +The intention of this codebase is to serve as a data proxy for the Source Cooperative. However, it should be structured in a way for others to use and build upon for their individual proxy needs. As such, a modular approach should be utilized to enable others to compose similar but different sytems. + +## Workspace Structure + +- `crates/libs/core` — Core proxy logic, traits, config, S3 request parsing +- `crates/libs/sts` — OIDC/STS token exchange (AssumeRoleWithWebIdentity, JWT validation) +- `crates/runtimes/server` — Tokio/Hyper server runtime +- `crates/runtimes/cf-workers` — Cloudflare Workers runtime (WASM) + +## Build Commands + +```bash +# Check/build default workspace members (excludes cf-workers) +cargo check +cargo build + +# CF Workers crate MUST be checked/built with the wasm32 target: +cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + +# Run tests +cargo test +``` + +## Key Architecture Notes + +- **Two-phase handler**: `ProxyHandler::resolve_request()` returns a `HandlerAction` enum: + - `Forward(ForwardRequest)` — presigned URL + headers for GET/HEAD/PUT/DELETE. The runtime executes the request with its native HTTP client, enabling zero-copy streaming. + - `Response(ProxyResult)` — complete response for LIST, errors, synthetic responses. + - `NeedsBody(PendingRequest)` — multipart operations that need the request body. The runtime materializes the body and calls `handle_with_body()`. +- **ProxyBackend trait**: `ProxyHandler` is generic over `B: ProxyBackend`, `R: RequestResolver`, and `O: OidcBackendAuth` (defaults to `NoOidcAuth`). The backend trait has three methods: `create_paginated_store()` returns a `Box` for LIST with backend-side pagination, `create_signer()` returns an `Arc` for presigned URL generation, and `send_raw()` sends pre-signed HTTP requests for multipart operations. Each runtime provides its own implementation: + - **Server**: `ServerBackend` delegates to `build_paginated_list_store()` (with default connector) and `build_signer()`, and uses reqwest for raw HTTP + Forward execution. + - **CF Workers**: `WorkerBackend` delegates to `build_paginated_list_store()` (injecting `FetchConnector`) and `build_signer()`, and uses `web_sys::fetch` for raw HTTP + Forward execution. +- **Multi-provider support**: `BucketConfig` uses a `backend_type: String` discriminator (`"s3"`, `"az"`, `"gcs"`) and a `backend_options: HashMap` for provider-specific config. `build_paginated_list_store()` in `crates/libs/core/src/backend.rs` dispatches on `backend_type`, iterating `backend_options` calling `with_config()` on the appropriate builder. `build_signer()` dispatches similarly: for authenticated backends it uses `object_store`'s built-in signer (WASM-safe because `StaticCredentialProvider` bypasses `Instant::now()`); for anonymous backends (no credentials) it returns `UnsignedUrlSigner` which constructs plain URLs without auth parameters (avoiding the `InstanceCredentialProvider` → `Instant::now()` panic on WASM). Azure and GCS builders are gated behind cargo features (`azure`, `gcp`) on `source-coop-core`. The server runtime enables both; the CF Workers runtime enables neither (only S3 is supported on WASM). Runtimes inject their HTTP connector via a closure over `StoreBuilder` for `build_paginated_list_store()` only — `build_signer()` needs no connector since signing is pure computation. +- **Operation dispatch** via presigned URLs and direct object_store: + - **GET/HEAD/PUT/DELETE** → `create_signer()` generates a presigned URL, returned as `HandlerAction::Forward`. The runtime executes the URL with its native HTTP client, streaming request/response bodies directly without handler involvement. + - **LIST** → `create_paginated_store()` + `store.list_paginated()` via `PaginatedListStore`; `max-keys`, `continuation-token`, and `start-after` are pushed to the backend, fetching only one page per request. Builds S3 ListObjectsV2 XML from the paginated `ListResult`. + - **Multipart** (CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload) → `NeedsBody` then raw signed HTTP via `backend.send_raw()` + `S3RequestSigner`. +- **ProxyResponseBody**: A simple enum (`Bytes`, `Empty`) for non-streaming responses only. Streaming bodies bypass this type entirely via the `Forward` action — runtimes handle them natively. +- **RequestResolver pattern**: The resolver decides what to do with each request (parse, auth, authorize, return proxy action or synthetic response). `DefaultResolver` handles standard S3 proxy behavior. Custom resolvers (e.g., `SourceCoopResolver` in cf-workers) implement product-specific namespace mapping and auth. Runtimes are thin adapters that pick a resolver and call `handler.resolve_request()`. +- **MaybeSend pattern**: Core traits use `MaybeSend`/`MaybeSync` (defined in `crates/libs/core/src/maybe_send.rs`) instead of `Send`/`Sync`. On native targets these resolve to `Send`/`Sync`; on `wasm32` they are no-op blanket traits. This allows the CF Workers runtime to use `!Send` JS interop types (`JsValue`, `ReadableStream`, etc.). The `Signer` trait from `object_store` requires real `Send + Sync`, which works because `UnsignedUrlSigner` only holds `String` fields and `object_store`'s built-in store types are `Send + Sync`. +- **FetchConnector** (CF Workers): `crates/runtimes/cf-workers/src/fetch_connector.rs` implements `object_store::client::HttpConnector` and `HttpService` using the Workers Fetch API. Since `worker::Fetch::send()` is `!Send`, each call is wrapped in `spawn_local` with a oneshot channel to bridge back to the `Send` context that `object_store` expects. Only exercised for LIST operations (presigned URL operations bypass `object_store` entirely). +- **Streaming via Forward pattern**: For GET, the runtime sends a presigned URL request and streams the response body directly to the client. For PUT, the runtime streams the client's request body directly to the presigned URL. On CF Workers, JS `ReadableStream` objects pass through without touching Rust. On the server, reqwest streams hyper `Incoming` bodies and `bytes_stream()` responses. +- **cf-workers is excluded from `default-members`** in the root `Cargo.toml` because WASM types are `!Send` and will fail to compile on native targets. Always use `--target wasm32-unknown-unknown` when working with this crate. +- **Config loading** (CF Workers): `PROXY_CONFIG` can be either a JSON string (via `wrangler secret`) or a JS object (via `[vars.PROXY_CONFIG]` table in `wrangler.toml`). Both formats are handled. +- **Sealed session tokens**: When `SESSION_TOKEN_KEY` is configured, temporary credentials minted by STS are AES-256-GCM encrypted into the session token itself (`sealed_token.rs`). On subsequent requests, `resolve_identity()` decrypts the token to recover credentials — no server-side storage or config lookup needed. This is required for stateless runtimes (CF Workers). `TokenKey` wraps `Arc` (Clone + Send + Sync). Token format: `base64url(nonce[12] || ciphertext + tag)`. Scopes are sealed at mint time, so config changes to `allowed_scopes` only affect newly minted credentials. The `DefaultResolver` accepts an optional `TokenKey` as its third constructor argument; the STS handler requires it when processing STS requests. +- **List response construction**: LIST responses are built from `object_store::ListResult` (one page at a time via `PaginatedListStore`) as S3 XML. When a resolver returns a `ListRewrite`, prefix stripping/adding is applied to `ObjectMeta.location` and `common_prefixes` paths before XML generation. The `list_rewrite` module in `crates/libs/core/src/s3/list_rewrite.rs` is retained for backward compatibility. +- **OIDC backend auth**: The `OidcBackendAuth` trait (`crates/libs/core/src/oidc_backend.rs`) resolves backend credentials via OIDC token exchange. When a bucket's `backend_options` contains `auth_type=oidc`, the proxy mints a self-signed JWT and exchanges it for temporary cloud credentials before the request reaches `create_paginated_store()`/`create_signer()`. The resolved credentials are injected into a cloned `BucketConfig.backend_options` so the existing builder pipeline works unmodified. `AwsOidcBackendAuth` (in `crates/libs/oidc-provider/src/backend_auth.rs`) implements this for AWS via `AssumeRoleWithWebIdentity`. `MaybeOidcAuth` is an enum (`Enabled`/`Disabled`) used as the concrete `O` type by both runtimes. OIDC is configured via `OIDC_PROVIDER_KEY` (PEM secret) and `OIDC_PROVIDER_ISSUER` (URL). When configured, `/.well-known/openid-configuration` and `/.well-known/jwks.json` are served for cloud provider JWKS discovery. The `S3RequestSigner` includes `x-amz-security-token` for STS temporary credentials. Currently AWS/S3 only; Azure and GCP exchange flows are TODO. + +## Known Limitations + +1. **Multipart uses raw HTTP (S3 only)**: `object_store`'s `MultipartUpload` API doesn't expose upload IDs. Multipart operations use `S3RequestSigner` + raw HTTP. They are gated to `backend_type == "s3"` — non-S3 backends return an error for multipart requests and should use `PUT` (object_store handles chunking internally). +2. **Azure/GCS require feature flags**: `MicrosoftAzureBuilder` and `GoogleCloudStorageBuilder` are gated behind cargo features (`azure`, `gcp`) on `source-coop-core`. The server runtime enables both; the CF Workers runtime only supports S3. Requesting an unsupported backend_type returns a `ConfigError`. + +## Style + +Don't support anything legacy. \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 5eac9d6..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,132 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, caste, color, religion, or sexual -identity and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall - community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or advances of - any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, - without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement at -[stac-spec-admins@googlegroups.com](mailto:stac-spec-admins@googlegroups.com). -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series of -actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or permanent -ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within the -community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.1, available at -[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. - -Community Impact Guidelines were inspired by -[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. - -For answers to common questions about this code of conduct, see the FAQ at -[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at -[https://www.contributor-covenant.org/translations][translations]. - -[homepage]: https://www.contributor-covenant.org -[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html -[Mozilla CoC]: https://github.com/mozilla/diversity -[FAQ]: https://www.contributor-covenant.org/faq -[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 9993ccd..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,90 +0,0 @@ -# How to contribute to Source Cooperative - -## Bugs - -- **Ensure the bug was not already reported** by searching on GitHub under [Issues](https://github.com/source-cooperative/data.source.coop/issues). - -- If you're unable to find an open issue addressing the problem, [open a new one](https://github.com/source-cooperative/data.source.coop/issues/new). Be sure to include a **title and clear description**, as much relevant information as possible, and a **code sample** or an **executable test case** demonstrating the expected behavior that is not occurring. - -- If possible, use the relevant bug report templates to create the issue. - -#### Did you write a patch that fixes a bug? - -- Open a new GitHub pull request with the patch. - -- Ensure the PR description clearly describes the problem and solution. Include the relevant issue number if applicable. - -## Features - -Prior to implementing a feature, it is recommended to [create an issue](https://github.com/source-cooperative/data.source.coop/issues/new) on GitHub and describe the new feature or change you would like to add. - -## General Communication - -Ask any question about how to use Source Cooperative in the [source-cooperative slack channel](https://join.slack.com/t/sourcecoop/shared_invite/zt-212sakf1j-fONCD4lZ_v2HP2PDpTr2dw). - -## Contributing Code - -To make contributions to this codebase, please create a pull request of a feature branch to the `main` branch. The PR title should conform to [Conventional Commits](http://conventionalcommits.org/en/v1.0.0/). - -> [!TIP] -> The `CHANGELOG.md` and the project version within `Cargo.toml` are managed automatically within our CICD pipeline. There is typically no need for individual developers to alter these values. - -### Releases - -Releases are automated via the [Release Please action](https://github.com/googleapis/release-please-action/). As contributions are made to `main`, a release PR will be kept up-to-date to represent the upcoming release. When that PR is merged, a new Github Release will be generated. - -### Deployments - -Merges to the `main` branch trigger deployment to the development instance of the proxy. - -New releases trigger deployment to the production instance of the proxy. - -
- -Manual Deployment Steps - -**⚠️ Manual deployment should only be necessary in extreme circumstances. Automated deployments via GitHub Workflows are preferred. ⚠️** - -## Deployment - -Before you begin the deployment process, ensure that you have the `SOURCE_KEY` environment variable set with the production key. - -### Tagging Release - -After committing your changes, tag the release and bump the version with the following command: - -``` -./scripts/tag-release.sh -``` - -### Building and Pushing Image - -To build and push the docker image to ECR, run the following command: - -``` -./scripts/build-push.sh -``` - -### Deploying to ECS - -To deploy the image to ECS, run the following command: - -``` -./scripts/deploy.sh -``` - -### Rolling Back a Deployment - -To roll back a deployment, first checkout the code for the version that you want to roll back to. For example: - -``` -git checkout v0.1.12 -``` - -Next, deploy the version to ECS: - -``` -./scripts/deploy.sh -``` - -
diff --git a/Cargo.lock b/Cargo.lock index 44235d8..80c63fb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,445 +3,417 @@ version = 4 [[package]] -name = "RustyXML" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5" - -[[package]] -name = "actix-codec" +name = "aead" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "bitflags 2.6.0", - "bytes", - "futures-core", - "futures-sink", - "memchr", - "pin-project-lite", - "tokio", - "tokio-util", - "tracing", + "crypto-common", + "generic-array", ] [[package]] -name = "actix-cors" -version = "0.7.0" +name = "aes" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "actix-utils", - "actix-web", - "derive_more", - "futures-util", - "log", - "once_cell", - "smallvec", + "cfg-if", + "cipher", + "cpufeatures", ] [[package]] -name = "actix-http" -version = "3.9.0" +name = "aes-gcm" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "actix-codec", - "actix-rt", - "actix-service", - "actix-tls", - "actix-utils", - "ahash", - "base64 0.22.1", - "bitflags 2.6.0", - "bytes", - "bytestring", - "derive_more", - "encoding_rs", - "futures-core", - "h2", - "http 0.2.12", - "httparse", - "httpdate", - "itoa", - "language-tags", - "local-channel", - "mime", - "percent-encoding", - "pin-project-lite", - "rand 0.8.5", - "sha1", - "smallvec", - "tokio", - "tokio-util", - "tracing", + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", ] [[package]] -name = "actix-macros" -version = "0.2.4" +name = "aho-corasick" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ - "quote", - "syn", + "memchr", ] [[package]] -name = "actix-router" -version = "0.5.3" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" -dependencies = [ - "bytestring", - "cfg-if", - "http 0.2.12", - "regex-lite", - "serde", - "tracing", -] +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "actix-rt" -version = "2.10.0" +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "futures-core", - "tokio", + "libc", ] [[package]] -name = "actix-server" -version = "2.5.0" +name = "anyhow" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" -dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "futures-util", - "mio", - "socket2", - "tokio", - "tracing", -] +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] -name = "actix-service" -version = "2.0.2" +name = "async-trait" +version = "0.1.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ - "futures-core", - "paste", - "pin-project-lite", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "actix-tls" -version = "3.4.0" +name = "atoi" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac453898d866cdbecdbc2334fe1738c747b4eba14a677261f2b768ba05329389" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" dependencies = [ - "actix-rt", - "actix-service", - "actix-utils", - "futures-core", - "impl-more", - "pin-project-lite", - "tokio", - "tokio-rustls 0.23.4", - "tokio-util", - "tracing", - "webpki-roots", + "num-traits", ] [[package]] -name = "actix-utils" -version = "3.0.1" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" -dependencies = [ - "local-waker", - "pin-project-lite", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "actix-web" -version = "4.9.0" +name = "autocfg" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" -dependencies = [ - "actix-codec", - "actix-http", - "actix-macros", - "actix-router", - "actix-rt", - "actix-server", - "actix-service", - "actix-tls", - "actix-utils", - "actix-web-codegen", - "ahash", - "bytes", - "bytestring", - "cfg-if", - "derive_more", - "encoding_rs", - "futures-core", - "futures-util", - "impl-more", - "itoa", - "language-tags", - "log", - "mime", - "once_cell", - "pin-project-lite", - "regex-lite", - "serde", - "serde_json", - "serde_urlencoded", - "smallvec", - "socket2", - "time", - "url", -] +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] -name = "actix-web-codegen" -version = "4.3.0" +name = "aws-credential-types" +version = "1.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +checksum = "e26bbf46abc608f2dc61fd6cb3b7b0665497cc259a21520151ed98f8b37d2c79" dependencies = [ - "actix-router", - "proc-macro2", - "quote", - "syn", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", ] [[package]] -name = "addr2line" -version = "0.22.0" +name = "aws-lc-rs" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ - "gimli", + "aws-lc-sys", + "zeroize", ] [[package]] -name = "adler" -version = "1.0.2" +name = "aws-lc-sys" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" - -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ - "cfg-if", - "getrandom 0.2.15", - "once_cell", - "version_check", - "zerocopy", + "cc", + "cmake", + "dunce", + "fs_extra", ] [[package]] -name = "aho-corasick" -version = "1.1.3" +name = "aws-runtime" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "b0f92058d22a46adf53ec57a6a96f34447daf02bff52e8fb956c66bcd5c6ac12" dependencies = [ - "memchr", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 1.4.0", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", ] [[package]] -name = "android-tzdata" -version = "0.1.1" +name = "aws-sdk-dynamodb" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +checksum = "82d2214c2ad3a175d3ece5a5af26916c29caa3e12e9e05b3cb8ed5e837b54b67" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "aws-sigv4" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "68f6ae9b71597dc5fd115d52849d7a5556ad9265885ad3492ea8d73b93bbc46e" dependencies = [ - "libc", + "aws-credential-types", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "percent-encoding", + "sha2", + "time", + "tracing", ] [[package]] -name = "anyhow" -version = "1.0.86" +name = "aws-smithy-async" +version = "1.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" +checksum = "3cba48474f1d6807384d06fec085b909f5807e16653c5af5c45dfe89539f0b70" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] [[package]] -name = "async-channel" -version = "1.9.0" +name = "aws-smithy-http" +version = "0.63.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +checksum = "af4a8a5fe3e4ac7ee871237c340bbce13e982d37543b65700f4419e039f5d78e" dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", ] [[package]] -name = "async-lock" -version = "3.4.0" +name = "aws-smithy-http-client" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +checksum = "0709f0083aa19b704132684bc26d3c868e06bd428ccc4373b0b55c3e8748a58b" dependencies = [ - "event-listener 5.3.1", - "event-listener-strategy", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.13", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.8.1", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.7", + "hyper-util", "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", ] [[package]] -name = "async-trait" -version = "0.1.81" +name = "aws-smithy-json" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0c28dcc82d7c8ead5cb13beb15405b57b8546e93215673ff8ca0349a028107" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" dependencies = [ - "proc-macro2", - "quote", - "syn", + "aws-smithy-types", ] [[package]] -name = "atty" -version = "0.2.14" +name = "aws-smithy-observability" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", + "aws-smithy-runtime-api", ] [[package]] -name = "autocfg" -version = "1.3.0" +name = "aws-smithy-runtime" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "8fd3dfc18c1ce097cf81fced7192731e63809829c6cbf933c1ec47452d08e1aa" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] [[package]] -name = "azure_core" -version = "0.20.0" +name = "aws-smithy-runtime-api" +version = "1.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ce3de4b65b1ee2667c81d1fc692949049502a4cf9c38118d811d6d79a7eaef" +checksum = "8c55e0837e9b8526f49e0b9bfa9ee18ddee70e853f5bc09c5d11ebceddcb0fec" dependencies = [ - "async-trait", - "base64 0.22.1", + "aws-smithy-async", + "aws-smithy-types", "bytes", - "dyn-clone", - "futures", - "getrandom 0.2.15", - "hmac 0.12.1", - "http-types", - "once_cell", - "paste", - "pin-project", - "quick-xml 0.31.0", - "rand 0.8.5", - "reqwest 0.12.5", - "rustc_version", - "serde", - "serde_json", - "sha2 0.10.8", - "time", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", "tracing", - "url", - "uuid", + "zeroize", ] [[package]] -name = "azure_storage" -version = "0.20.0" +name = "aws-smithy-types" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9713002fc30956a9f4061cdbc2e912ff739c6160e138ad3b6d992b3bcedccc6d" +checksum = "576b0d6991c9c32bc14fc340582ef148311f924d41815f641a308b5d11e8e7cd" dependencies = [ - "RustyXML", - "async-lock", - "async-trait", - "azure_core", + "base64-simd", "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", "serde", - "serde_derive", "time", - "tracing", - "url", - "uuid", + "tokio", + "tokio-util", ] [[package]] -name = "azure_storage_blobs" -version = "0.20.0" +name = "aws-types" +version = "1.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5b3a31dd8f920739437b827d0c9f9a4011eb3f06f79a121764aa11af6c51ee2" +checksum = "6c50f3cdf47caa8d01f2be4a6663ea02418e892f9bbfd82c7b9a3a37eaccdd3a" dependencies = [ - "RustyXML", - "azure_core", - "azure_storage", - "azure_svc_blobstorage", - "bytes", - "futures", - "serde", - "serde_derive", - "serde_json", - "time", + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", "tracing", - "url", - "uuid", ] [[package]] -name = "azure_svc_blobstorage" -version = "0.20.0" +name = "axum" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef37ba6180df451042f1c277d4d0898e2447f0a5d5072e0ff11ee6ea5e7ef38" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "azure_core", + "axum-core", "bytes", - "futures", - "log", - "once_cell", - "serde", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit 0.8.4", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", "serde_json", - "time", + "serde_path_to_error", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", ] [[package]] -name = "backtrace" -version = "0.3.73" +name = "axum-core" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "addr2line", - "cc", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -449,24 +421,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bitflags" -version = "1.3.2" +name = "base64-simd" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] [[package]] -name = "bitflags" -version = "2.6.0" +name = "base64ct" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] -name = "block-buffer" -version = "0.9.0" +name = "bitflags" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" dependencies = [ - "generic-array", + "serde_core", ] [[package]] @@ -480,9 +456,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.16.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -492,58 +468,75 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.7.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8318a53db07bb3f8dca91a600466bdb3f2eaadeedfdbcf02e1accbad9271ba50" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "bytestring" -version = "1.3.1" +name = "bytes-utils" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d80203ea6b29df88012294f62733de21cfeab47f17b41af3a38bc30a03ee72" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" dependencies = [ "bytes", + "either", ] [[package]] name = "cc" -version = "1.1.10" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e8aabfac534be767c909e0690571677d49f41bd8465ae876fe043d52ba5292" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] [[package]] name = "cfg-if" -version = "1.0.0" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a21f936df1771bf62b77f047b726c4625ff2e8aa607c01ec06e5a05bd8463401" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ - "android-tzdata", "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "common-s3-headers" -version = "1.0.0" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f177814b579c39ae2325720be922d21fab28ff22fe81aa27c79625326ce19db" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "hex", - "hmac 0.12.1", - "percent-encoding", - "sha2 0.10.8", - "time", - "url", + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", ] [[package]] @@ -556,16 +549,26 @@ dependencies = [ ] [[package]] -name = "convert_case" -version = "0.4.0" +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] name = "core-foundation" -version = "0.9.4" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", @@ -579,105 +582,81 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" -version = "0.2.13" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51e852e6dc9a5bed1fae92dd2375037bf2b768725bf3be87811edee3249d09ad" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] -name = "crc32fast" -version = "1.4.2" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "cfg-if", + "crc-catalog", ] [[package]] -name = "crossbeam-channel" -version = "0.5.13" +name = "crc-catalog" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" -dependencies = [ - "crossbeam-utils", -] +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] [[package]] -name = "crypto-mac" -version = "0.11.1" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "generic-array", - "subtle", + "cipher", ] [[package]] -name = "ct-logs" -version = "0.8.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1a816186fa68d9e426e3cb4ae4dff1fcd8e4a2c34b781bf7a822574a0d0aac8" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "sct 0.6.1", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] name = "deranged" -version = "0.3.11" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", - "serde", -] - -[[package]] -name = "derive_more" -version = "0.99.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - -[[package]] -name = "digest" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" -dependencies = [ - "generic-array", ] [[package]] @@ -686,87 +665,76 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer 0.10.4", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", + "block-buffer", + "const-oid", + "crypto-common", + "subtle", ] [[package]] -name = "dirs-sys-next" -version = "0.1.2" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ - "libc", - "redox_users", - "winapi", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "dyn-clone" -version = "1.0.17" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6ef0072f8a535281e4876be788938b528e9a1d43900b82c2569af7da799125" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "encoding_rs" -version = "0.8.34" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" -dependencies = [ - "cfg-if", -] +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "env_logger" -version = "0.9.3" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "atty", - "humantime", - "log", - "regex", - "termcolor", + "serde", ] [[package]] name = "equivalent" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.9" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "event-listener" -version = "2.5.3" +name = "etcetera" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] [[package]] name = "event-listener" -version = "5.3.1" +version = "5.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" dependencies = [ "concurrent-queue", "parking", @@ -774,29 +742,27 @@ dependencies = [ ] [[package]] -name = "event-listener-strategy" -version = "0.5.2" +name = "fastrand" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" -dependencies = [ - "event-listener 5.3.1", - "pin-project-lite", -] +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] -name = "fastrand" -version = "1.9.0" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "fastrand" -version = "2.1.0" +name = "flume" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] [[package]] name = "fnv" @@ -805,34 +771,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "form_urlencoded" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -845,9 +808,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -855,15 +818,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -871,31 +834,27 @@ dependencies = [ ] [[package]] -name = "futures-io" -version = "0.3.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" - -[[package]] -name = "futures-lite" -version = "1.13.0" +name = "futures-intrusive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ - "fastrand 1.9.0", "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", + "lock_api", + "parking_lot", ] +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", @@ -904,21 +863,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -944,39 +903,59 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.1.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi 0.9.0+wasi-snapshot-preview1", + "wasi", + "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.2.15" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.0+wasi-snapshot-preview1", + "r-efi", + "wasip2", "wasm-bindgen", ] [[package]] -name = "gimli" -version = "0.29.0" +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] [[package]] name = "h2" -version = "0.3.26" +version = "0.3.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" dependencies = [ "bytes", "fnv", @@ -991,26 +970,56 @@ dependencies = [ "tracing", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "hashlink" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "libc", + "hashbrown 0.15.5", ] [[package]] -name = "hermit-abi" -version = "0.3.9" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" @@ -1019,13 +1028,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hmac" -version = "0.11.0" +name = "hkdf" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "crypto-mac", - "digest 0.9.0", + "hmac", ] [[package]] @@ -1034,7 +1042,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest 0.10.7", + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", ] [[package]] @@ -1050,12 +1067,11 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1077,47 +1093,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.1.0", + "http 1.4.0", ] [[package]] name = "http-body-util" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", - "futures-util", - "http 1.1.0", + "futures-core", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] -[[package]] -name = "http-types" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" -dependencies = [ - "anyhow", - "async-channel", - "base64 0.13.1", - "futures-lite", - "infer", - "pin-project-lite", - "rand 0.7.3", - "serde", - "serde_json", - "serde_qs", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" -version = "1.9.4" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "httpdate" @@ -1127,28 +1123,28 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" [[package]] name = "hyper" -version = "0.14.30" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a152ddd61dfaec7273fe8419ab357f33aee0d914c5f4efbf0d96fa749eea5ec9" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" dependencies = [ "bytes", "futures-channel", "futures-core", "futures-util", - "h2", + "h2 0.3.27", "http 0.2.12", "http-body 0.4.6", "httparse", "httpdate", "itoa", "pin-project-lite", - "socket2", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1157,18 +1153,22 @@ dependencies = [ [[package]] name = "hyper" -version = "1.4.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ + "atomic-waker", "bytes", "futures-channel", - "futures-util", - "http 1.1.0", + "futures-core", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "httparse", + "httpdate", "itoa", "pin-project-lite", + "pin-utils", "smallvec", "tokio", "want", @@ -1176,80 +1176,71 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.22.1" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f9f7a97316d44c0af9b0301e65010573a853a9fc97046d7331d7f6bc0fd5a64" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" dependencies = [ - "ct-logs", "futures-util", - "hyper 0.14.30", + "http 0.2.12", + "hyper 0.14.32", "log", - "rustls 0.19.1", - "rustls-native-certs", - "tokio", - "tokio-rustls 0.22.0", - "webpki 0.21.4", -] - -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.30", - "native-tls", + "rustls 0.21.12", "tokio", - "tokio-native-tls", + "tokio-rustls 0.24.1", ] [[package]] -name = "hyper-tls" -version = "0.6.0" +name = "hyper-rustls" +version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "bytes", - "http-body-util", - "hyper 1.4.1", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", - "native-tls", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", "tokio", - "tokio-native-tls", + "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] name = "hyper-util" -version = "0.1.7" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cde7055719c54e36e95e8719f95883f22072a48ede39db7fc17a4e1d5281e9b9" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", - "http 1.1.0", + "http 1.4.0", "http-body 1.0.1", - "hyper 1.4.1", + "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.2", "tokio", - "tower", "tower-service", "tracing", ] [[package]] name = "iana-time-zone" -version = "0.1.60" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", + "log", "wasm-bindgen", "windows-core", ] @@ -1263,151 +1254,297 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "idna" -version = "0.5.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "impl-more" -version = "0.1.6" +name = "idna_adapter" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "206ca75c9c03ba3d4ace2460e57b189f39f43de612c2f85836e65c929701bb2d" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] [[package]] name = "indexmap" -version = "2.3.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] -name = "infer" -version = "0.2.3" +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] -name = "instant" -version = "0.1.13" +name = "iri-string" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ - "cfg-if", + "memchr", + "serde", ] [[package]] -name = "ipnet" -version = "2.9.0" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] -name = "js-sys" -version = "0.3.70" +name = "jobserver" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "wasm-bindgen", + "getrandom 0.3.4", + "libc", ] [[package]] -name = "language-tags" -version = "0.3.2" +name = "js-sys" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] [[package]] -name = "libc" -version = "0.2.155" +name = "litemap" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] -name = "libredox" -version = "0.1.3" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "bitflags 2.6.0", - "libc", + "scopeguard", ] [[package]] -name = "linux-raw-sys" -version = "0.4.14" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "local-channel" -version = "0.1.5" +name = "lru-slab" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" -dependencies = [ - "futures-core", - "futures-sink", - "local-waker", -] +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "local-waker" -version = "0.1.4" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] [[package]] -name = "lock_api" -version = "0.4.12" +name = "matchit" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" -dependencies = [ - "autocfg", - "scopeguard", -] +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] -name = "log" -version = "0.4.22" +name = "matchit" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.9.1" +version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "block-buffer 0.9.0", - "digest 0.9.0", - "opaque-debug", + "cfg-if", + "digest", ] [[package]] name = "memchr" -version = "2.7.4" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "mime" @@ -1416,73 +1553,66 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "miniz_oxide" -version = "0.7.4" +name = "mio" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ - "adler", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "mio" -version = "1.0.2" +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "hermit-abi 0.3.9", - "libc", - "log", - "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] -name = "moka" -version = "0.12.8" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cf62eb4dd975d2dde76432fb1075c49e3ee2331cf36f1f8fd4b66550d32b6f" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" dependencies = [ - "async-lock", - "async-trait", - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "event-listener 5.3.1", - "futures-util", - "once_cell", - "parking_lot", - "quanta", - "rustc_version", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", "smallvec", - "tagptr", - "thiserror 1.0.63", - "triomphe", - "uuid", + "zeroize", ] [[package]] -name = "native-tls" -version = "0.2.12" +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", + "num-traits", ] [[package]] -name = "num-conv" -version = "0.1.0" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] [[package]] name = "num-traits" @@ -1491,22 +1621,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] -name = "object" -version = "0.36.3" +name = "object_store" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b64972346851a39438c60b341ebc01bba47464ae329e55cf343eb93964efd9" +checksum = "c2858065e55c148d294a9f3aae3b0fa9458edadb41a108397094566f4e3c0dfb" dependencies = [ - "memchr", + "async-trait", + "base64", + "bytes", + "chrono", + "form_urlencoded", + "futures", + "http 1.4.0", + "http-body-util", + "httparse", + "humantime", + "hyper 1.8.1", + "itertools", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml 0.38.4", + "rand 0.9.2", + "reqwest", + "ring", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", + "web-time", ] [[package]] name = "once_cell" -version = "1.19.0" +version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "opaque-debug" @@ -1514,61 +1673,29 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openssl" -version = "0.10.66" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" -dependencies = [ - "bitflags 2.6.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "openssl-probe" -version = "0.1.5" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "openssl-sys" -version = "0.9.103" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parking" -version = "2.2.0" +version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb813b8af86854136c6922af0598d719255ecb2179515e6e7730d468f05c9cae" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.3" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -1576,43 +1703,46 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.10" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "paste" -version = "1.0.15" +name = "pem-rfc7468" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] [[package]] name = "percent-encoding" -version = "2.3.1" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", @@ -1621,9 +1751,9 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" @@ -1631,11 +1761,53 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" -version = "0.3.30" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] [[package]] name = "powerfmt" @@ -1645,42 +1817,37 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" -version = "0.2.20" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] -name = "proc-macro2" -version = "1.0.95" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "unicode-ident", + "proc-macro2", + "syn", ] [[package]] -name = "quanta" -version = "0.12.3" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "crossbeam-utils", - "libc", - "once_cell", - "raw-cpuid", - "wasi 0.11.0+wasi-snapshot-preview1", - "web-sys", - "winapi", + "unicode-ident", ] [[package]] name = "quick-xml" -version = "0.31.0" +version = "0.37.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" dependencies = [ "memchr", "serde", @@ -1688,36 +1855,84 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.36.1" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a05e2e8efddfa51a84ca47cec303fac86c8541b686d37cac5efc0e094417bc" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", "serde", ] [[package]] -name = "quote" -version = "1.0.36" +name = "quinn" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ - "proc-macro2", + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.36", + "socket2 0.6.2", + "thiserror", + "tokio", + "tracing", + "web-time", ] [[package]] -name = "rand" -version = "0.7.3" +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.36", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "getrandom 0.1.16", + "cfg_aliases", "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", + "once_cell", + "socket2 0.6.2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "rand" version = "0.8.5" @@ -1730,13 +1945,13 @@ dependencies = [ ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1750,12 +1965,13 @@ dependencies = [ ] [[package]] -name = "rand_core" -version = "0.5.1" +name = "rand_chacha" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ - "getrandom 0.1.16", + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1764,64 +1980,41 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.15", -] - -[[package]] -name = "rand_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", + "getrandom 0.2.17", ] [[package]] -name = "raw-cpuid" -version = "11.2.0" +name = "rand_core" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ - "bitflags 2.6.0", + "getrandom 0.3.4", ] [[package]] name = "redox_syscall" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" -dependencies = [ - "bitflags 2.6.0", -] - -[[package]] -name = "redox_users" -version = "0.4.5" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd283d9651eeda4b2a83a43c1c91b266c40fd76ecd39a50a8c630ae69dc72891" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "getrandom 0.2.15", - "libredox", - "thiserror 1.0.63", + "bitflags", ] [[package]] -name = "regex" -version = "1.10.6" +name = "redox_syscall" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4219d74c6b67a3654a9fbebc4b419e22126d13d2f3c4a07ee0cb61ff79a79619" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "bitflags", ] [[package]] name = "regex-automata" -version = "0.4.7" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -1830,314 +2023,198 @@ dependencies = [ [[package]] name = "regex-lite" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" - -[[package]] -name = "regex-syntax" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" - -[[package]] -name = "reqwest" -version = "0.11.27" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.30", - "hyper-tls 0.5.0", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile 1.0.4", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "winreg 0.50.0", -] +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "reqwest" -version = "0.12.5" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", "futures-util", - "http 1.1.0", + "h2 0.4.13", + "http 1.4.0", "http-body 1.0.1", "http-body-util", - "hyper 1.4.1", - "hyper-tls 0.6.0", + "hyper 1.8.1", + "hyper-rustls 0.27.7", "hyper-util", - "ipnet", "js-sys", "log", - "mime", - "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", - "rustls-pemfile 2.1.3", + "quinn", + "rustls 0.23.36", + "rustls-native-certs", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.1", + "sync_wrapper", "tokio", - "tokio-native-tls", + "tokio-rustls 0.26.4", "tokio-util", + "tower", + "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "winreg 0.52.0", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", + "webpki-roots 1.0.6", ] [[package]] name = "ring" -version = "0.17.8" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.15", + "getrandom 0.2.17", "libc", - "spin 0.9.8", - "untrusted 0.9.0", + "untrusted", "windows-sys 0.52.0", ] [[package]] -name = "rusoto_core" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b4f000e8934c1b4f70adde180056812e7ea6b1a247952db8ee98c94cd3116cc" -dependencies = [ - "async-trait", - "base64 0.13.1", - "bytes", - "crc32fast", - "futures", - "http 0.2.12", - "hyper 0.14.30", - "hyper-rustls", - "lazy_static", - "log", - "rusoto_credential", - "rusoto_signature", - "rustc_version", - "serde", - "serde_json", - "tokio", - "xml-rs", -] - -[[package]] -name = "rusoto_credential" -version = "0.47.0" +name = "rsa" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a46b67db7bb66f5541e44db22b0a02fed59c9603e146db3a9e633272d3bac2f" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "async-trait", - "chrono", - "dirs-next", - "futures", - "hyper 0.14.30", - "serde", - "serde_json", - "shlex", - "tokio", + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", "zeroize", ] [[package]] -name = "rusoto_s3" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "048c2fe811a823ad5a9acc976e8bf4f1d910df719dcf44b15c3e96c5b7a51027" -dependencies = [ - "async-trait", - "bytes", - "futures", - "rusoto_core", - "xml-rs", -] - -[[package]] -name = "rusoto_signature" -version = "0.47.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6264e93384b90a747758bcc82079711eacf2e755c3a8b5091687b5349d870bcc" -dependencies = [ - "base64 0.13.1", - "bytes", - "chrono", - "digest 0.9.0", - "futures", - "hex", - "hmac 0.11.0", - "http 0.2.12", - "hyper 0.14.30", - "log", - "md-5", - "percent-encoding", - "pin-project-lite", - "rusoto_credential", - "rustc_version", - "serde", - "sha2 0.9.9", - "tokio", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" -dependencies = [ - "bitflags 2.6.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.52.0", -] - [[package]] name = "rustls" -version = "0.19.1" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ - "base64 0.13.1", "log", - "ring 0.16.20", - "sct 0.6.1", - "webpki 0.21.4", + "ring", + "rustls-webpki 0.101.7", + "sct", ] [[package]] name = "rustls" -version = "0.20.9" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "log", - "ring 0.16.20", - "sct 0.7.1", - "webpki 0.22.4", + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.5.0" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a07b7c1885bd8ed3831c289b7870b13ef46fe0e856d288c30d9cc17d75a2092" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", - "rustls 0.19.1", + "rustls-pki-types", "schannel", "security-framework", ] [[package]] -name = "rustls-pemfile" -version = "1.0.4" +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "base64 0.21.7", + "ring", + "untrusted", ] [[package]] -name = "rustls-pemfile" -version = "2.1.3" +name = "rustls-webpki" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "196fe16b00e106300d3e45ecfcb764fa292a535d7326a29a5875c579c7417425" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ - "base64 0.22.1", + "aws-lc-rs", + "ring", "rustls-pki-types", + "untrusted", ] [[package]] -name = "rustls-pki-types" -version = "1.8.0" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0a2ce646f8655401bb81e7927b812614bd5d91dbc968696be50603510fcaf0" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.18" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "schannel" -version = "0.1.23" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2146,33 +2223,23 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" -dependencies = [ - "ring 0.16.20", - "untrusted 0.7.1", -] - [[package]] name = "sct" version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] name = "security-framework" -version = "2.11.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ - "bitflags 2.6.0", + "bitflags", "core-foundation", "core-foundation-sys", "libc", @@ -2181,9 +2248,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.11.1" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75da29fe9b9b08fe9d6b22b5b4bcbc75d8db3aa31e639aa56bb62e9d46bfceaf" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -2191,36 +2258,45 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.23" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.207" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5665e14a49a4ea1b91029ba7d3bca9f299e1f7cfa194388ccc20f14743e784f2" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] [[package]] -name = "serde-xml-rs" -version = "0.6.0" +name = "serde-wasm-bindgen" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3aa78ecda1ebc9ec9847d5d3aba7d618823446a049ba2491940506da6e2782" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" dependencies = [ - "log", + "js-sys", "serde", - "thiserror 1.0.63", - "xml-rs", + "wasm-bindgen", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.207" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aea2634c86b0e8ef2cfdc0c340baede54ec27b1e46febd7f80dffb2aa44a00e" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -2229,25 +2305,35 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.141" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] -name = "serde_qs" -version = "0.8.5" +name = "serde_path_to_error" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ - "percent-encoding", "serde", - "thiserror 1.0.63", ] [[package]] @@ -2270,31 +2356,27 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest 0.10.7", + "digest", ] [[package]] name = "sha2" -version = "0.9.9" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "block-buffer 0.9.0", "cfg-if", "cpufeatures", - "digest 0.9.0", - "opaque-debug", + "digest", ] [[package]] -name = "sha2" -version = "0.10.8" +name = "sharded-slab" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "lazy_static", ] [[package]] @@ -2305,192 +2387,480 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.2" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "source-coop-api" +version = "1.0.4" +dependencies = [ + "bytes", + "http 1.4.0", + "serde", + "serde_json", + "source-coop-core", + "tracing", + "url", +] + +[[package]] +name = "source-coop-cf-workers" +version = "1.0.4" +dependencies = [ + "async-trait", + "axum", + "bytes", + "chrono", + "console_error_panic_hook", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "js-sys", + "object_store", + "quick-xml 0.37.5", + "reqwest", + "serde", + "serde_json", + "source-coop-api", + "source-coop-core", + "source-coop-oidc-provider", + "source-coop-sts", + "thiserror", + "tracing", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "worker", +] + +[[package]] +name = "source-coop-core" +version = "1.0.4" +dependencies = [ + "aes-gcm", + "async-trait", + "aws-sdk-dynamodb", + "axum", + "base64", + "bytes", + "chrono", + "futures", + "hex", + "hmac", + "http 1.4.0", + "object_store", + "quick-xml 0.37.5", + "reqwest", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror", + "tokio", + "toml", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "source-coop-oidc-provider" +version = "1.0.4" +dependencies = [ + "async-trait", + "base64", + "chrono", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "source-coop-core", + "thiserror", + "tokio", + "tracing", + "uuid", +] + +[[package]] +name = "source-coop-server" +version = "1.0.4" +dependencies = [ + "axum", + "bytes", + "futures", + "http 1.4.0", + "http-body-util", + "object_store", + "reqwest", + "serde", + "source-coop-api", + "source-coop-core", + "source-coop-oidc-provider", + "source-coop-sts", + "thiserror", + "tokio", + "toml", + "tower-service", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "source-coop-sts" +version = "1.0.4" +dependencies = [ + "async-trait", + "base64", + "chrono", + "quick-xml 0.37.5", + "rand 0.8.5", + "reqwest", + "rsa", + "serde", + "serde_json", + "sha2", + "source-coop-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" dependencies = [ - "libc", + "lock_api", ] [[package]] -name = "slab" -version = "0.4.9" +name = "spki" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ - "autocfg", + "base64ct", + "der", ] [[package]] -name = "smallvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" - -[[package]] -name = "socket2" -version = "0.5.7" +name = "sqlx" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "libc", - "windows-sys 0.52.0", + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", ] [[package]] -name = "source-data-proxy" -version = "1.0.4" +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "actix-cors", - "actix-http", - "actix-web", - "async-trait", - "azure_core", - "azure_storage", - "azure_storage_blobs", + "base64", "bytes", "chrono", - "common-s3-headers", - "env_logger", - "futures", + "crc", + "crossbeam-queue", + "either", + "event-listener", "futures-core", + "futures-intrusive", + "futures-io", "futures-util", - "hex", - "hmac 0.12.1", + "hashbrown 0.15.5", + "hashlink", + "indexmap", "log", - "moka", + "memchr", + "once_cell", "percent-encoding", - "pin-project-lite", - "quick-xml 0.36.1", - "reqwest 0.11.27", - "rusoto_core", - "rusoto_credential", - "rusoto_s3", + "rustls 0.23.36", "serde", - "serde-xml-rs", "serde_json", - "sha2 0.10.8", - "thiserror 2.0.12", - "time", + "sha2", + "smallvec", + "thiserror", "tokio", - "tokio-util", + "tokio-stream", + "tracing", "url", - "xml-rs", + "webpki-roots 0.26.11", ] [[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "subtle" -version = "2.4.1" +name = "sqlx-macros" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] [[package]] -name = "syn" -version = "2.0.101" +name = "sqlx-macros-core" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", "proc-macro2", "quote", - "unicode-ident", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", ] [[package]] -name = "sync_wrapper" -version = "0.1.2" +name = "sqlx-mysql" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] [[package]] -name = "sync_wrapper" -version = "1.0.1" +name = "sqlx-postgres" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] [[package]] -name = "system-configuration" -version = "0.5.1" +name = "sqlx-sqlite" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ - "bitflags 1.3.2", - "core-foundation", - "system-configuration-sys", + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", ] [[package]] -name = "system-configuration-sys" -version = "0.5.0" +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" dependencies = [ - "core-foundation-sys", - "libc", + "unicode-bidi", + "unicode-normalization", + "unicode-properties", ] [[package]] -name = "tagptr" -version = "0.2.0" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "tempfile" -version = "3.12.0" +name = "syn" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbcdd0c794ebb0d4cf35e88edd2f7d2c4c3e9a5a6dab322839b321c6a87a64" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ - "cfg-if", - "fastrand 2.1.0", - "once_cell", - "rustix", - "windows-sys 0.59.0", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ - "winapi-util", + "futures-core", ] [[package]] -name = "thiserror" -version = "1.0.63" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0342370b38b6a11b6cc11d6a805569958d54cfa061a29969c3b5ce2ea405724" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "thiserror-impl 1.0.63", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.63" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4558b58466b9ad7ca0f102865eccc95938dca1a74a856f2b57b6629050da261" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2498,53 +2868,59 @@ dependencies = [ ] [[package]] -name = "thiserror-impl" -version = "2.0.12" +name = "thread_local" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", ] [[package]] name = "time" -version = "0.3.36" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", - "js-sys", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.2" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.18" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] @@ -2557,27 +2933,26 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.39.2" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daa4fb1bc778bd6f04cbfc4bb2d06a7396a8f299dc33ea1900cedaa316f467b1" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ - "backtrace", "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.2", "tokio-macros", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.4.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", @@ -2585,42 +2960,41 @@ dependencies = [ ] [[package]] -name = "tokio-native-tls" -version = "0.3.1" +name = "tokio-rustls" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" dependencies = [ - "native-tls", + "rustls 0.21.12", "tokio", ] [[package]] name = "tokio-rustls" -version = "0.22.0" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc6844de72e57df1980054b38be3a9f4702aba4858be64dd700181a8a6d0e1b6" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.19.1", + "rustls 0.23.36", "tokio", - "webpki 0.21.4", ] [[package]] -name = "tokio-rustls" -version = "0.23.4" +name = "tokio-stream" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43ee83903113e03984cb9e5cebe6c04a5116269e900e3ddba8f068a62adda59" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ - "rustls 0.20.9", + "futures-core", + "pin-project-lite", "tokio", - "webpki 0.22.4", ] [[package]] name = "tokio-util" -version = "0.7.11" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -2629,21 +3003,80 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" -version = "0.4.13" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "pin-project", "pin-project-lite", + "sync_wrapper", "tokio", "tower-layer", "tower-service", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2658,9 +3091,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.40" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -2670,9 +3103,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.27" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -2681,18 +3114,42 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" dependencies = [ + "log", "once_cell", + "tracing-core", ] [[package]] -name = "triomphe" -version = "0.1.11" +name = "tracing-subscriber" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859eb650cfee7434994602c3a68b25d77ad9e68c8a6cd491616ef86661382eb3" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] [[package]] name = "try-lock" @@ -2702,36 +3159,52 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.17.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "unicode-bidi" -version = "0.3.15" +version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-normalization" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ "tinyvec", ] [[package]] -name = "untrusted" -version = "0.7.1" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] [[package]] name = "untrusted" @@ -2741,9 +3214,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.2" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -2751,16 +3224,29 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" -version = "1.10.0" +version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" dependencies = [ - "getrandom 0.2.15", - "serde", + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -2774,10 +3260,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "waker-fn" -version = "1.2.0" +name = "vsimd" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" [[package]] name = "want" @@ -2790,59 +3276,66 @@ dependencies = [ [[package]] name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] -name = "wasi" -version = "0.11.0+wasi-snapshot-preview1" +name = "wasip2" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] [[package]] -name = "wasm-bindgen" -version = "0.2.93" +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "cfg-if", - "once_cell", - "wasm-bindgen-macro", + "wit-bindgen", ] [[package]] -name = "wasm-bindgen-backend" -version = "0.2.93" +name = "wasite" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" -dependencies = [ - "bumpalo", - "log", +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", "once_cell", - "proc-macro2", - "quote", - "syn", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", + "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2850,28 +3343,53 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] [[package]] name = "wasm-streams" -version = "0.4.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65dc4c90b63b118468cf747d8bf3566c1913ef60be765b5730ead9e0a3ba129" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" dependencies = [ "futures-util", "js-sys", @@ -2880,83 +3398,123 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] -name = "webpki" -version = "0.21.4" +name = "web-time" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ - "ring 0.16.20", - "untrusted 0.7.1", + "js-sys", + "wasm-bindgen", ] [[package]] -name = "webpki" -version = "0.22.4" +name = "webpki-roots" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "webpki-roots 1.0.6", ] [[package]] name = "webpki-roots" -version = "0.22.6" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c71e40d7d2c34a5106301fb632274ca37242cd0c9d3e64dbece371a40a2d87" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ - "webpki 0.22.4", + "rustls-pki-types", ] [[package]] -name = "winapi" -version = "0.3.9" +name = "whoami" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "libredox", + "wasite", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-core" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] [[package]] -name = "winapi-util" -version = "0.1.9" +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ - "windows-sys 0.59.0", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-core" -version = "0.52.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", ] [[package]] @@ -2979,11 +3537,20 @@ dependencies = [ [[package]] name = "windows-sys" -version = "0.59.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.52.6", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", ] [[package]] @@ -3010,13 +3577,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3029,6 +3613,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3041,6 +3631,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3053,12 +3649,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3071,6 +3679,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3083,6 +3697,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3095,6 +3715,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3108,54 +3734,278 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "winreg" -version = "0.50.0" +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ - "cfg-if", - "windows-sys 0.48.0", + "memchr", ] [[package]] -name = "winreg" -version = "0.52.0" +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "worker" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "244647fd7673893058f91f22a0eabd0f45bb50298e995688cb0c4b9837081b19" +dependencies = [ + "async-trait", + "axum", + "bytes", + "chrono", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "js-sys", + "matchit 0.7.3", + "pin-project", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_urlencoded", + "tokio", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "worker-macros", + "worker-sys", +] + +[[package]] +name = "worker-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7e73ffb164183b57bb67d3efb881681fcd93ef5515ba32a4d022c4a6acc2ce" +dependencies = [ + "async-trait", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-macro-support", + "worker-sys", +] + +[[package]] +name = "worker-sys" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +checksum = "2a2b96254fcaa9229fd82d886f04be99c4ee8e59c8d80438724aa70039dca838" dependencies = [ "cfg-if", - "windows-sys 0.48.0", + "js-sys", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "xml-rs" -version = "0.8.21" +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "539a77ee7c0de333dcc6da69b177380a0b81e0dacfa4f7344c465a36871ee601" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ - "byteorder", "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", + "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bc126c7..da479bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,54 +1,83 @@ -[package] -name = "source-data-proxy" +[workspace] +members = [ + "crates/libs/core", + "crates/libs/sts", + "crates/libs/oidc-provider", + "crates/libs/api", + "crates/runtimes/server", + "crates/runtimes/cf-workers", +] +# Worker crate is excluded from default builds because it contains !Send +# WASM types that only compile correctly for wasm32 targets. Build it +# separately via: cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown +default-members = [ + "crates/libs/core", + "crates/libs/sts", + "crates/libs/oidc-provider", + "crates/libs/api", + "crates/runtimes/server", +] +resolver = "2" +[workspace.package] version = "1.0.4" edition = "2021" +license = "MIT OR Apache-2.0" -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] - -quick-xml = { version = "=0.36.1", features = ["serialize"] } -actix-web = { version = "^4", features = [ - "rustls", - "macros", -], default-features = false } -rusoto_core = { version = "0.47", default-features = false, features = [ - "rustls", -] } -rusoto_s3 = { version = "0.47", default-features = false, features = [ - "rustls", -] } -rusoto_credential = { version = "0.47" } -tokio-util = { version = "0.7", features = ["codec"] } -tokio = { version = "1", features = ["full"] } -futures-util = "0.3" -xml-rs = "0.8" -serde = { version = "1.0", features = ["derive"] } -serde-xml-rs = "0.6" -bytes = "1.0" -pin-project-lite = "0.2" -futures = "0.3" -futures-core = "0.3" -log = "0.4" -env_logger = "0.9" +[workspace.dependencies] +# Core +async-trait = "0.1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "0.8" +bytes = "1" +http = "1" chrono = { version = "0.4", features = ["serde"] } -async-trait = "0.1.81" -azure_storage_blobs = "0.20.0" -azure_storage = "0.20.0" -azure_core = "0.20.0" -time = { version = "0.3", features = ["formatting"] } -url = "2.2.2" -reqwest = { version = "0.11.0", features = ["stream", "json"] } -actix-cors = "0.7.0" -moka = { version = "0.12.8", features = ["future"] } -percent-encoding = "2.1.0" -sha2 = "0.10.6" -hex = "0.4.3" +uuid = { version = "1", features = ["v4", "js"] } +base64 = "0.22" +rand = "0.8" +hex = "0.4" +url = "2" + +# Crypto hmac = "0.12" -actix-http = "^3" -thiserror = "2.0.12" -serde_json = "1.0.141" +sha2 = { version = "0.10", features = ["oid"] } +rsa = "0.9" +aes-gcm = "0.10" + +# Object store +object_store = { version = "0.13.1", default-features = false, features = ["aws"] } +futures = "0.3" + +# XML +quick-xml = { version = "0.37", features = ["serialize"] } + +# Auth +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } + +# Config backends +aws-sdk-dynamodb = "1" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono"] } + +# HTTP body +http-body = "1" + +# Web framework +axum = { version = "0.8", default-features = false } +tower-service = "0.3" + +# Server runtime +tokio = { version = "1", features = ["full"] } +hyper = { version = "1", features = ["full"] } +hyper-util = { version = "0.1", features = ["full"] } +http-body-util = "0.1" +tower = { version = "0.5", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } -[dev-dependencies] -common-s3-headers = "1.0.0" +# Internal crates +source-coop-core = { path = "crates/libs/core" } +source-coop-sts = { path = "crates/libs/sts" } +source-coop-oidc-provider = { path = "crates/libs/oidc-provider" } +source-coop-api = { path = "crates/libs/api" } diff --git a/Dockerfile b/Dockerfile index f8fc9b8..64ddcf7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,49 +1,17 @@ -# Build stage - target x86_64 for ECS Fargate compatibility -# Use bookworm variant to match runtime stage GLIBC version -FROM --platform=linux/amd64 rust:1.90.0-bookworm AS builder +FROM rust:1.82-slim AS builder -# Set environment variables for consistent builds -ENV CARGO_TARGET_DIR=/app/target -ENV RUSTFLAGS="-C target-cpu=x86-64" - -# Copy source code -COPY . /app WORKDIR /app +COPY . . -# Add x86_64 target and build -RUN rustup target add x86_64-unknown-linux-gnu -RUN cargo build --release --target x86_64-unknown-linux-gnu - -# Runtime stage - minimal Debian image -FROM --platform=linux/amd64 debian:bookworm-slim AS runtime - -# Install runtime dependencies (ca-certificates for HTTPS requests) -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - && rm -rf /var/lib/apt/lists/* +RUN cargo build --release --package source-coop-server --bin source-coop-proxy -# Create app user for security -RUN groupadd -r appuser && useradd -r -g appuser appuser +FROM debian:bookworm-slim -# Copy the built binary from builder stage -COPY --from=builder /app/target/x86_64-unknown-linux-gnu/release/source-data-proxy /app/source-data-proxy +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* -# Set proper permissions -RUN chown appuser:appuser /app/source-data-proxy && \ - chmod +x /app/source-data-proxy +COPY --from=builder /app/target/release/source-coop-proxy /usr/local/bin/source-coop-proxy -# Switch to non-root user -USER appuser - -# Set working directory and expose port -WORKDIR /app EXPOSE 8080 -# Health check endpoint (using root path which returns version info) -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:8080/ || exit 1 - -# Run the binary directly -ENTRYPOINT ["/app/source-data-proxy"] +ENTRYPOINT ["source-coop-proxy"] +CMD ["--config", "/etc/source-coop-proxy/config.toml", "--listen", "0.0.0.0:8080"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 16d2966..0000000 --- a/LICENSE +++ /dev/null @@ -1,7 +0,0 @@ -Copyright (c) 2024 Radiant Earth - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6653ed4 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: check test run-server run-workers ci setup + +check: + cargo check + +check-wasm: + cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + +fmt: + cargo fmt -- --check +fmt-fix: + cargo fmt + +clippy: + cargo clippy -- -D warnings +clippy-fix: + cargo clippy --fix --allow-dirty --allow-staged + +test: + cargo test + +run-server: + cargo run -p source-coop-server -- $(ARGS) + +run-workers: + npx wrangler dev --cwd crates/runtimes/cf-workers + +ci-fast: fmt clippy check-wasm +ci: ci-fast test + +setup: + git config core.hooksPath .githooks diff --git a/README.md b/README.md index f264726..40c5bdd 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,390 @@ -# Source Cooperative Data Proxy +# source data proxy -This repository contains the rust application which hosts the Source Cooperative Data Proxy. +A multi-runtime S3 gateway that streams requests to and from backing object stores (S3, MinIO, R2, etc.), providing a unified S3-compliant API with configurable authentication and authorization. -## Getting Started +## Architecture -### Prerequisites +```mermaid +flowchart + Clients["S3 Clients
(aws cli, boto3, sdk, etc.)"] -- Cargo installed on your local machine -- The AWS CLI installed on your local machine + subgraph Proxy["source-coop-proxy"] + Auth["Auth
(STS, OIDC)"] + Core["Core
(SigV4, Proxy)"] + Config["Config
(Static, HTTP API, DynamoDB, Postgres)"] + end -### Run Locally + Backend["Backend S3
(AWS, MinIO, R2, etc.)"] + + Proxy <--> Clients + Backend <--> Proxy -To run the data proxy locally, run the following command: +``` + +### Crate Layout + +```sh +crates/ +├── cli/ # source-coop CLI (OIDC login → STS credential exchange) +├── libs/ # Libraries — not directly runnable +│ ├── core/ (source-coop-core) # Runtime-agnostic: traits, S3 parsing, SigV4, config providers +│ ├── sts/ (source-coop-sts) # OIDC/STS token exchange (AssumeRoleWithWebIdentity) +│ ├── oidc-provider/ # Outbound OIDC provider (JWT signing, JWKS, credential exchange) +│ └── source-coop/ # Source Cooperative resolver and API client +└── runtimes/ # Runnable targets — one per deployment platform + ├── server/ (source-coop-server) # Tokio/Hyper for container deployments + └── cf-workers/ (source-coop-cf-workers) # Cloudflare Workers for edge deployments +``` + +Libraries define trait abstractions (`ProxyBackend`, `ConfigProvider`, `RequestResolver`). Runtimes implement `ProxyBackend` with platform-native primitives. The handler uses a two-phase dispatch model: `resolve_request()` returns a `HandlerAction` — either a `Forward` (presigned URL for GET/HEAD/PUT/DELETE), a `Response` (LIST, errors), or `NeedsBody` (multipart). Runtimes execute `Forward` requests with their native HTTP client, enabling zero-copy streaming. + +The `RequestResolver` trait decouples "what to do with a request" from the proxy handler. A `DefaultResolver` handles standard S3 proxy behavior (parse, auth, authorize via `ConfigProvider`), while custom resolvers like `SourceCoopResolver` can implement entirely different namespace mapping and authorization schemes. + +## Supported Operations + +- `GET` (GetObject) — download files +- `HEAD` (HeadObject) — file metadata +- `PUT` (PutObject) — upload files +- `POST` (CreateMultipartUpload, CompleteMultipartUpload) — multipart uploads +- `PUT` with `partNumber` + `uploadId` — upload individual parts +- `DELETE` with `uploadId` — abort multipart uploads +- `GET` on bucket root — ListBucket (v2) +- STS `AssumeRoleWithWebIdentity` — OIDC token exchange + +## Quick Start + +### Local Development (Docker Compose) + +The fastest way to get a running environment with MinIO as the backing store: + +```bash +docker compose up +``` + +This starts MinIO (`:9000` API, `:9001` console) and a seed job that creates example buckets with test data. Then run the proxy using either runtime: + +```bash +# Option A: native server runtime +cargo run -p source-coop-server -- --config config.local.toml --listen 0.0.0.0:8080 +# Option B: Cloudflare Workers runtime (via Wrangler) +cd crates/runtimes/cf-workers && npx wrangler dev ``` -./scripts/run.sh + +Test it: + +```bash +# Anonymous read (port 8080 for server, 8787 for worker) +curl http://localhost:8080/public-data/hello.txt + +# Signed upload with the local dev credential +AWS_ACCESS_KEY_ID=AKLOCAL0000000000001 \ +AWS_SECRET_ACCESS_KEY="localdev/secret/key/00000000000000000000" \ +aws s3 cp ./myfile.txt s3://private-uploads/myfile.txt \ + --endpoint-url http://localhost:8080 + +# Browse MinIO directly +open http://localhost:9001 # user: minioadmin / pass: minioadmin ``` + +The server runtime reads `config.local.toml` (TOML, backend endpoints use `http://localhost:9000`). The worker runtime reads `PROXY_CONFIG` from `crates/runtimes/cf-workers/wrangler.toml` (JSON, same endpoints). + +### Container Deployment + +```bash +# Build +cargo build --release -p source-coop-server + +# Run with a config file +./target/release/source-coop-proxy --config config.toml --listen 0.0.0.0:8080 + +# Or with Docker +docker build -t source-coop-proxy . +docker run -v ./config.toml:/etc/source-coop-proxy/config.toml -p 8080:8080 source-coop-proxy +``` + +### Client Usage + +```bash +# Anonymous access to a public bucket +curl http://localhost:8080/public-data/path/to/file.txt + +# Signed request with aws-cli (using long-lived credentials) +aws s3 cp s3://ml-artifacts/models/latest.pt ./latest.pt \ + --endpoint-url http://localhost:8080 + +# GitHub Actions OIDC → STS → S3 +# Step 1: Exchange OIDC token for temporary credentials +CREDS=$(aws sts assume-role-with-web-identity \ + --role-arn github-actions-deployer \ + --web-identity-token "$ACTIONS_ID_TOKEN" \ + --endpoint-url http://localhost:8080) + +# Step 2: Use temporary credentials to access S3 +AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r .AccessKeyId) \ +AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r .SecretAccessKey) \ +AWS_SESSION_TOKEN=$(echo $CREDS | jq -r .SessionToken) \ +aws s3 cp ./bundle.tar.gz s3://deploy-bundles/releases/v1.2.3.tar.gz \ + --endpoint-url http://localhost:8080 +``` + +## Configuration + +See `config.example.toml` for a full example. The config defines three things: virtual buckets (mapping client-visible names to backing stores), IAM roles (trust policies for OIDC token exchange), and long-lived credentials (static access keys). + +### Configuration Providers + +The proxy supports multiple backends for retrieving configuration, selectable at build time via feature flags or by choosing a different provider at runtime. + +#### Static File (always available) + +```rust +use source_coop_core::config::static_file::StaticProvider; + +let provider = StaticProvider::from_file("config.toml")?; +// or +let provider = StaticProvider::from_toml(include_str!("../config.toml"))?; +// or from JSON (useful for Workers env vars) +let provider = StaticProvider::from_json(&json_string)?; +``` + +#### HTTP API (`config-http` feature) + +Fetches config from a centralized REST API. Useful with a control plane service. + +```rust +use source_coop_core::config::http::HttpProvider; + +let provider = HttpProvider::new( + "https://config-api.internal:8080".to_string(), + Some("Bearer my-api-token".to_string()), +); +``` + +Expected endpoints: `GET /buckets`, `GET /buckets/{name}`, `GET /roles/{id}`, etc. + +#### DynamoDB (`config-dynamodb` feature) + +Single-table design with PK/SK pattern. Build with: + +```bash +cargo build -p source-coop-server --features source-coop-core/config-dynamodb +``` + +```rust +use source_coop_core::config::dynamodb::DynamoDbProvider; + +let client = aws_sdk_dynamodb::Client::new(&aws_config); +let provider = DynamoDbProvider::new(client, "source-coop-proxy-config".to_string()); +``` + +#### PostgreSQL (`config-postgres` feature) + +```bash +cargo build -p source-coop-server --features source-coop-core/config-postgres +``` + +```rust +use source_coop_core::config::postgres::PostgresProvider; + +let pool = sqlx::PgPool::connect("postgres://localhost/s3proxy").await?; +let provider = PostgresProvider::new(pool); +``` + +#### Implementing a Custom Provider + +Implement the `ConfigProvider` trait to plug in your own config backend. Then wrap it in `DefaultResolver` to get standard S3 proxy behavior (path/virtual-host parsing, auth, authorization): + +```rust +use source_coop_core::config::ConfigProvider; +use source_coop_core::error::ProxyError; +use source_coop_core::types::*; + +#[derive(Clone)] +struct MyProvider { /* ... */ } + +impl ConfigProvider for MyProvider { + async fn list_buckets(&self) -> Result, ProxyError> { todo!() } + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { todo!() } + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { todo!() } + async fn get_credential(&self, access_key_id: &str) -> Result, ProxyError> { todo!() } +} +``` + +#### Implementing a Custom Request Resolver + +For full control over request routing, authentication, and namespace mapping, implement the `RequestResolver` trait directly. This is useful when your URL namespace doesn't map to a simple bucket/key structure, or when authorization is handled by an external service. + +```rust +use source_coop_core::resolver::{RequestResolver, ResolvedAction, ListRewrite}; +use source_coop_core::error::ProxyError; +use source_coop_core::types::BucketConfig; +use http::{Method, HeaderMap}; +use bytes::Bytes; + +#[derive(Clone)] +struct MyResolver { /* ... */ } + +impl RequestResolver for MyResolver { + async fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> Result { + // Parse the path, authenticate, authorize, and return either: + // - ResolvedAction::Proxy { operation, bucket_config, list_rewrite } + // to forward to a backend + // - ResolvedAction::Response { status, headers, body } + // to return a synthetic response (e.g., virtual directory listing) + todo!() + } +} +``` + +The `ResolvedAction::Proxy` variant supports an optional `ListRewrite` for rewriting `` and `` values in S3 list response XML, which is useful when the backend prefix differs from what clients expect. + +Wire it into the proxy handler in your runtime: + +```rust +let resolver = MyResolver::new(/* ... */); +let handler = ProxyHandler::new(backend, resolver); +let action = handler.resolve_request(method, path, query, &headers).await; +// Handle action: Forward (presigned URL), Response, or NeedsBody (multipart) +``` + +### Caching Configuration + +Wrap any provider with `CachedProvider` to add in-memory TTL-based caching. This is recommended for all network-backed providers. + +```rust +use source_coop_core::config::cached::CachedProvider; +use std::time::Duration; + +// Wrap any provider with a 5-minute cache +let base = HttpProvider::new("https://config-api.internal".into(), None); +let provider = CachedProvider::new(base, Duration::from_secs(300)); + +// First call hits the backend; subsequent calls within TTL return cached data. +// Temporary credential operations (store/get) bypass the cache. + +// Manual invalidation is also available: +provider.invalidate_all(); +provider.invalidate_bucket("my-bucket"); +``` + +The cache is thread-safe (`RwLock`-based) and evicts entries lazily on access. + +### Roles + +Roles define trust policies for OIDC token exchange via `AssumeRoleWithWebIdentity`. Each role specifies which OIDC issuers to trust, optional audience and subject constraints, and the access scopes granted to minted credentials. + +```toml +[[roles]] +role_id = "github-actions-deployer" +name = "GitHub Actions Deploy Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audience = "sts.s3proxy.example.com" +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", + "repo:myorg/infrastructure:*", +] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] +actions = ["get_object", "head_object", "put_object"] +``` + +| Field | Description | +| --------------------------- | ------------------------------------------------------------------------ | +| `role_id` | Identifier used as the `RoleArn` in STS requests | +| `trusted_oidc_issuers` | OIDC provider URLs whose tokens are accepted | +| `required_audience` | If set, the token's `aud` claim must match | +| `subject_conditions` | Glob patterns matched against the `sub` claim (`*` matches any sequence) | +| `max_session_duration_secs` | Upper bound for session duration (clients can request less) | +| `allowed_scopes` | List of `{ bucket, prefixes, actions }` granted to minted credentials | + +#### Template Variables in Access Scopes + +Access scope `bucket` and `prefixes` values support `{claim_name}` template variables that are resolved against the authenticated user's JWT claims at credential mint time. This enables per-user bucket access without defining a separate role for each user. + +```toml +[[roles]] +role_id = "source-coop-user" +name = "Source Cooperative User" +trusted_oidc_issuers = ["https://auth.source.coop"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +# Each user gets read/write access to a bucket matching their OIDC subject +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +A user with `sub = "alice"` receives credentials scoped to `bucket = "alice"`. Any JWT string claim can be referenced (e.g., `{email}`, `{org}`). Missing or non-string claims resolve to an empty string, which will fail authorization safely. + +## Authentication Flows + +### 1. Anonymous Access + +Buckets with `anonymous_access = true` serve GET/HEAD/LIST requests without any authentication. Write operations still require credentials. + +### 2. Long-Lived Access Keys + +Static `AccessKeyId`/`SecretAccessKey` pairs stored in the config backend. Clients sign requests using standard AWS SigV4. Each credential has an associated set of access scopes (buckets, prefixes, allowed actions). + +### 3. OIDC → STS → Temporary Credentials + +Modeled after AWS `AssumeRoleWithWebIdentity`. This is the recommended approach for CI/CD workloads. + +**GitHub Actions example workflow:** + +```yaml +jobs: + deploy: + permissions: + id-token: write # Required for OIDC token + steps: + - name: Get OIDC token + id: oidc + run: | + TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.s3proxy.example.com" | jq -r '.value') + echo "token=$TOKEN" >> $GITHUB_OUTPUT + + - name: Assume role via STS + run: | + aws sts assume-role-with-web-identity \ + --role-arn github-actions-deployer \ + --web-identity-token ${{ steps.oidc.outputs.token }} \ + --endpoint-url https://s3proxy.example.com +``` + +The proxy validates the JWT against the OIDC provider's JWKS, checks the trust policy (issuer, audience, subject conditions with glob matching), and mints temporary credentials scoped to the role's allowed buckets/prefixes. + +**Sealed session tokens:** When `SESSION_TOKEN_KEY` is configured (a base64-encoded 32-byte AES-256-GCM key), the full `TemporaryCredentials` are encrypted into the session token itself. The proxy decrypts the token on each subsequent request — no server-side credential storage is needed. This is required for stateless runtimes like Cloudflare Workers. Generate a key with: + +```bash +openssl rand -base64 32 +``` + +**OIDC backend auth:** When `OIDC_PROVIDER_KEY` (PEM-encoded RSA private key) and `OIDC_PROVIDER_ISSUER` (publicly reachable URL) are configured, the proxy acts as its own OIDC identity provider for backend authentication. Buckets configured with `auth_type=oidc` and `oidc_role_arn` in their `backend_options` will have credentials resolved automatically — the proxy mints a self-signed JWT, exchanges it with the cloud provider's STS for temporary credentials, and caches them. This eliminates the need to store long-lived backend credentials. The proxy serves `/.well-known/openid-configuration` and `/.well-known/jwks.json` for cloud provider JWKS discovery. Currently supports AWS S3 only. + +## Multi-Runtime Design + +The crate workspace separates concerns so the core logic compiles to both native and WASM targets: + +**`source-coop-core`** has zero runtime dependencies. No `tokio`, no `worker`. It uses `object_store`'s `Signer` trait to generate presigned URLs for GET/HEAD/PUT/DELETE, and `object_store` directly for LIST. A `ProxyBackend` trait provides runtime-specific store/signer creation and raw HTTP (multipart). All async trait methods use `impl Future` (RPITIT) rather than `#[async_trait]` with `Send` bounds, so they compile on single-threaded WASM runtimes. + +**`source-coop-server`** adds Tokio, Hyper, and reqwest. It handles `Forward` actions by executing presigned URLs via reqwest — streaming the Hyper `Incoming` body for PUT and the reqwest `bytes_stream()` for GET responses. No buffering. Multipart and LIST go through the handler's `Response` path. + +**`source-coop-cf-workers`** adds `worker-rs`, `wasm-bindgen`, and `web-sys`. It handles `Forward` actions by passing JS `ReadableStream` bodies directly through the Fetch API — zero Rust stream involvement. `FetchConnector` bridges `object_store` to the Workers Fetch API (used only for LIST). + +## License + +MIT OR Apache-2.0 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..b35253d --- /dev/null +++ b/config.example.toml @@ -0,0 +1,140 @@ +# s3-proxy configuration example +# +# This file defines the virtual buckets, IAM roles, and long-lived +# credentials that the proxy serves. + +# ============================================================================= +# Virtual Buckets +# ============================================================================= + +# A publicly accessible S3 bucket (anonymous reads allowed) +[[buckets]] +name = "public-data" +backend_type = "s3" +anonymous_access = true +allowed_roles = [] + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-company-public-assets" +region = "us-east-1" +access_key_id = "AKIAIOSFODNN7EXAMPLE" +secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + +# A private S3 bucket backed by MinIO +[[buckets]] +name = "ml-artifacts" +backend_type = "s3" +backend_prefix = "v2" +anonymous_access = false +allowed_roles = ["github-actions-deployer"] + +[buckets.backend_options] +endpoint = "https://minio.internal:9000" +bucket_name = "ml-pipeline-artifacts" +region = "us-east-1" +access_key_id = "minioadmin" +secret_access_key = "minioadmin" + +# An S3 bucket on a different region +[[buckets]] +name = "deploy-bundles" +backend_type = "s3" +anonymous_access = false +allowed_roles = ["github-actions-deployer", "ci-readonly"] + +[buckets.backend_options] +endpoint = "https://s3.us-west-2.amazonaws.com" +bucket_name = "prod-deploy-bundles" +region = "us-west-2" +access_key_id = "AKIAI44QH8DHBEXAMPLE" +secret_access_key = "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY" + +# An Azure Blob Storage backend +[[buckets]] +name = "azure-data" +backend_type = "az" +anonymous_access = true +allowed_roles = [] + +[buckets.backend_options] +account_name = "mystorageaccount" +container_name = "public-datasets" + +# ============================================================================= +# IAM Roles (for STS AssumeRoleWithWebIdentity) +# ============================================================================= + +# Role for GitHub Actions CI/CD pipelines +[[roles]] +role_id = "github-actions-deployer" +name = "GitHub Actions Deploy Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audience = "sts.s3proxy.example.com" +# Only allow specific repos/branches +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", + "repo:myorg/myapp:ref:refs/heads/release/*", + "repo:myorg/infrastructure:*", +] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/", "datasets/"] +actions = ["get_object", "head_object", "put_object", "create_multipart_upload", "upload_part", "complete_multipart_upload"] + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] # full bucket access +actions = ["get_object", "head_object", "put_object", "create_multipart_upload", "upload_part", "complete_multipart_upload"] + +# Role for Source Cooperative CLI users (OIDC login via Ory) +[[roles]] +role_id = "source-user" +name = "Source Cooperative User" +trusted_oidc_issuers = ["https://auth.source.coop", "https://auth.staging.source.coop"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +# Template variables like {sub} are resolved against the user's JWT claims. +# This gives each user read/write access to a bucket matching their username. +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] + +# Read-only role for CI +[[roles]] +role_id = "ci-readonly" +name = "CI Read-Only Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +subject_conditions = ["repo:myorg/*"] +max_session_duration_secs = 1800 + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] + +# ============================================================================= +# Long-Lived Credentials +# ============================================================================= + +# Service account for an internal tool +[[credentials]] +access_key_id = "AKPROXY00000EXAMPLE" +secret_access_key = "proxy/secret/key/EXAMPLE000000000000" +principal_name = "internal-dashboard" +created_at = "2024-01-15T00:00:00Z" +enabled = true + +[[credentials.allowed_scopes]] +bucket = "public-data" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] + +[[credentials.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/production/"] +actions = ["get_object", "head_object"] diff --git a/crates/libs/api/Cargo.toml b/crates/libs/api/Cargo.toml new file mode 100644 index 0000000..afe4239 --- /dev/null +++ b/crates/libs/api/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "source-coop-api" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Source Cooperative API client and request resolver for the S3 proxy gateway" + +[dependencies] +source-coop-core.workspace = true +serde.workspace = true +serde_json.workspace = true +http.workspace = true +bytes.workspace = true +url.workspace = true +tracing.workspace = true diff --git a/crates/libs/api/src/api.rs b/crates/libs/api/src/api.rs new file mode 100644 index 0000000..8af4143 --- /dev/null +++ b/crates/libs/api/src/api.rs @@ -0,0 +1,305 @@ +//! HTTP client for the Source Cooperative API. +//! +//! Makes server-to-server calls to resolve products, data connections, +//! API keys, and permissions. The actual HTTP transport is abstracted behind +//! the [`HttpClient`] trait so each runtime can provide its own implementation. + +use serde::de::DeserializeOwned; +use serde::Deserialize; +use source_coop_core::error::ProxyError; +use source_coop_core::maybe_send::{MaybeSend, MaybeSync}; +use std::collections::HashMap; +use std::future::Future; + +/// Options for response caching. +pub struct CacheOptions { + pub cache_ttl: u32, + pub cache_key: Option, +} + +/// Trait abstracting HTTP JSON fetching so each runtime can provide its own implementation. +/// +/// `fetch_json` returns `Ok(Some(T))` on 2xx, `Ok(None)` on 404 (resource not +/// found), and `Err` for all other failures (network errors, 5xx, 429, etc.). +pub trait HttpClient: Clone + MaybeSend + MaybeSync + 'static { + fn fetch_json( + &self, + url: &str, + headers: &[(&str, &str)], + cache: Option<&CacheOptions>, + ) -> impl Future, ProxyError>> + MaybeSend; +} + +/// Per-endpoint cache TTLs (seconds). Set to 0 to disable caching. +pub struct CacheTtls { + pub product: u32, + pub data_connection: u32, + pub permissions: u32, + pub account: u32, + pub api_key: u32, +} + +impl Default for CacheTtls { + fn default() -> Self { + Self { + product: 5 * 60, + data_connection: 30 * 60, + permissions: 60, + account: 5 * 60, + api_key: 60, + } + } +} + +/// Client for the Source Cooperative API. +#[derive(Clone)] +pub struct SourceApiClient { + http: H, + api_url: String, + api_key: String, + product_cache_ttl: u32, + data_connection_cache_ttl: u32, + permissions_cache_ttl: u32, + account_cache_ttl: u32, + api_key_cache_ttl: u32, +} + +// -- API response types -- + +#[derive(Debug, Deserialize)] +pub struct SourceProduct { + pub disabled: bool, + pub data_mode: String, + pub metadata: ProductMetadata, +} + +#[derive(Debug, Deserialize)] +pub struct ProductMetadata { + pub primary_mirror: String, + pub mirrors: HashMap, +} + +#[derive(Debug, Deserialize)] +pub struct ProductMirror { + pub connection_id: String, + pub prefix: String, +} + +#[derive(Debug, Deserialize)] +pub struct DataConnection { + pub details: ConnectionDetails, + #[serde(default)] + pub authentication: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct ConnectionDetails { + pub provider: String, + pub region: Option, + pub base_prefix: Option, + pub bucket: Option, + #[serde(default)] + pub account_name: Option, + #[serde(default)] + pub container_name: Option, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +pub struct ConnectionAuth { + #[serde(alias = "type")] + pub auth_type: String, + pub access_key_id: Option, + pub secret_access_key: Option, + #[serde(default)] + pub access_key: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SourceApiKey { + pub access_key_id: String, + pub secret_access_key: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub enum RepositoryPermission { + Read, + Write, +} + +/// API response for the permissions endpoint. +/// +/// The API may return permission fields as booleans (`true`/`false`) or as +/// strings (e.g., `"read"`, `"write"`). The custom deserializer handles both. +#[derive(Debug, Deserialize)] +pub struct PermissionsResponse { + #[serde(default, deserialize_with = "deserialize_truthy")] + pub read: bool, + #[serde(default, deserialize_with = "deserialize_truthy")] + pub write: bool, +} + +/// Deserialize a value as `true` if it is a boolean `true` or any non-empty string. +fn deserialize_truthy<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + use serde::de; + + struct TruthyVisitor; + + impl<'de> de::Visitor<'de> for TruthyVisitor { + type Value = bool; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a boolean or a string") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(v) + } + + fn visit_str(self, v: &str) -> Result { + Ok(!v.is_empty()) + } + } + + deserializer.deserialize_any(TruthyVisitor) +} + +/// API response for listing products under an account. +#[derive(Debug, Deserialize)] +pub struct AccountResponse { + #[serde(default)] + pub products: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct AccountProduct { + pub product_id: String, +} + +// -- Client implementation -- + +impl SourceApiClient { + pub fn new(http: H, api_url: String, api_key: String, cache_ttls: CacheTtls) -> Self { + Self { + http, + api_url, + api_key, + product_cache_ttl: cache_ttls.product, + data_connection_cache_ttl: cache_ttls.data_connection, + permissions_cache_ttl: cache_ttls.permissions, + account_cache_ttl: cache_ttls.account, + api_key_cache_ttl: cache_ttls.api_key, + } + } + + fn auth_headers(&self) -> Vec<(&str, String)> { + vec![("authorization", self.api_key.clone())] + } + + /// `GET /api/v1/products/{account_id}/{repo_id}` + /// + /// Returns `Ok(None)` when the API returns 404 (product does not exist). + pub async fn get_product( + &self, + account_id: &str, + repo_id: &str, + ) -> Result, ProxyError> { + let url = format!( + "{}/api/v1/products/{}/{}", + self.api_url, account_id, repo_id + ); + let auth = self.auth_headers(); + let headers: Vec<(&str, &str)> = auth.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let cache = (self.product_cache_ttl > 0).then_some(CacheOptions { + cache_ttl: self.product_cache_ttl, + cache_key: None, + }); + self.http.fetch_json(&url, &headers, cache.as_ref()).await + } + + /// `GET /api/v1/data-connections/{id}` + /// + /// Returns `Ok(None)` when the API returns 404 (connection does not exist). + pub async fn get_data_connection( + &self, + id: &str, + ) -> Result, ProxyError> { + let url = format!("{}/api/v1/data-connections/{}", self.api_url, id); + let auth = self.auth_headers(); + let headers: Vec<(&str, &str)> = auth.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let cache = (self.data_connection_cache_ttl > 0).then_some(CacheOptions { + cache_ttl: self.data_connection_cache_ttl, + cache_key: None, + }); + self.http.fetch_json(&url, &headers, cache.as_ref()).await + } + + /// `GET /api/v1/api-keys/{access_key_id}/auth` + /// + /// Returns `Ok(None)` when the API returns 404 (key does not exist). + pub async fn get_api_key( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + let url = format!("{}/api/v1/api-keys/{}/auth", self.api_url, access_key_id); + let auth = self.auth_headers(); + let headers: Vec<(&str, &str)> = auth.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let cache = (self.api_key_cache_ttl > 0).then_some(CacheOptions { + cache_ttl: self.api_key_cache_ttl, + cache_key: None, + }); + self.http.fetch_json(&url, &headers, cache.as_ref()).await + } + + /// `GET /api/v1/products/{account_id}/{repo_id}/permissions` + /// + /// Uses the *user's* API key (not the server key) to check permissions + /// for the authenticated user. + /// + /// A custom `cacheKey` incorporating the user's API key prevents + /// cross-user cache poisoning (the URL is the same for all users). + /// + /// Returns `Ok(None)` when the API returns 404. + pub async fn get_permissions( + &self, + account_id: &str, + repo_id: &str, + user_api_key: &str, + ) -> Result, ProxyError> { + let url = format!( + "{}/api/v1/products/{}/{}/permissions", + self.api_url, account_id, repo_id + ); + let headers = [("authorization", user_api_key)]; + let cache = (self.permissions_cache_ttl > 0).then(|| CacheOptions { + cache_ttl: self.permissions_cache_ttl, + cache_key: Some(format!( + "source-perms:{}:{}:{}", + account_id, repo_id, user_api_key + )), + }); + self.http.fetch_json(&url, &headers, cache.as_ref()).await + } + + /// `GET /api/v1/accounts/{account_id}` + /// + /// Returns `Ok(None)` when the API returns 404 (account does not exist). + pub async fn list_account_repos( + &self, + account_id: &str, + ) -> Result, ProxyError> { + let url = format!("{}/api/v1/products/{}", self.api_url, account_id); + let auth = self.auth_headers(); + let headers: Vec<(&str, &str)> = auth.iter().map(|(k, v)| (*k, v.as_str())).collect(); + let cache = (self.account_cache_ttl > 0).then_some(CacheOptions { + cache_ttl: self.account_cache_ttl, + cache_key: None, + }); + self.http.fetch_json(&url, &headers, cache.as_ref()).await + } +} diff --git a/crates/libs/api/src/lib.rs b/crates/libs/api/src/lib.rs new file mode 100644 index 0000000..57563d8 --- /dev/null +++ b/crates/libs/api/src/lib.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod resolver; diff --git a/crates/libs/api/src/resolver.rs b/crates/libs/api/src/resolver.rs new file mode 100644 index 0000000..49c3840 --- /dev/null +++ b/crates/libs/api/src/resolver.rs @@ -0,0 +1,439 @@ +//! [`RequestResolver`] implementation for Source Cooperative. +//! +//! Consolidates all Source Cooperative business logic (URL namespace mapping, +//! external API auth, query/response prefix rewriting, synthetic XML responses) +//! into a single resolver that thin runtime adapters call. + +use crate::api::{HttpClient, SourceApiClient}; +use bytes::Bytes; +use http::{HeaderMap, Method}; +use source_coop_core::error::ProxyError; +use source_coop_core::resolver::{ListRewrite, RequestResolver, ResolvedAction}; +use source_coop_core::s3::request::build_s3_operation; +use source_coop_core::types::BucketConfig; +use std::collections::HashMap; + +/// Request resolver for Source Cooperative. +/// +/// Routes requests based on the URL namespace: +/// - `/` -> synthetic empty ListBuckets +/// - `/{account_id}` -> synthetic account listing or list-with-prefix +/// - `/{account_id}/{repo_id}[/{key}]` -> proxy to backend +#[derive(Clone)] +pub struct SourceCoopResolver { + api_client: SourceApiClient, +} + +impl SourceCoopResolver { + pub fn new(api_client: SourceApiClient) -> Self { + Self { api_client } + } + + /// Resolve a bucket config from the Source API for a given account/repo. + async fn resolve_bucket_config( + &self, + account_id: &str, + repo_id: &str, + ) -> Result { + let bucket_name = format!("{}/{}", account_id, repo_id); + + let product = self + .api_client + .get_product(account_id, repo_id) + .await? + .ok_or_else(|| ProxyError::BucketNotFound(bucket_name.clone()))?; + + if product.disabled { + return Err(ProxyError::BucketNotFound(bucket_name)); + } + + let mirror = product + .metadata + .mirrors + .get(&product.metadata.primary_mirror) + .ok_or_else(|| { + ProxyError::ConfigError(format!( + "primary mirror '{}' not found in product mirrors", + product.metadata.primary_mirror + )) + })?; + + let conn = self + .api_client + .get_data_connection(&mirror.connection_id) + .await? + .ok_or_else(|| { + ProxyError::ConfigError(format!( + "data connection '{}' not found", + mirror.connection_id + )) + })?; + + let base_prefix = conn.details.base_prefix.unwrap_or_default(); + + let backend_prefix = { + let bp = base_prefix.trim_end_matches('/'); + let mp = mirror.prefix.trim_end_matches('/'); + if bp.is_empty() && mp.is_empty() { + None + } else if bp.is_empty() { + Some(mp.to_string()) + } else if mp.is_empty() { + Some(bp.to_string()) + } else { + Some(format!("{}/{}", bp, mp)) + } + }; + + let provider = conn.details.provider.as_str(); + let backend_options = match provider { + "s3" => { + let region = conn.details.region.unwrap_or_else(|| "us-east-1".into()); + let bucket = conn.details.bucket.unwrap_or_default(); + let endpoint = format!("https://s3.{}.amazonaws.com", region); + let mut opts = HashMap::new(); + opts.insert("endpoint".to_string(), endpoint); + opts.insert("bucket_name".to_string(), bucket); + opts.insert("region".to_string(), region); + if let Some(ref auth) = conn.authentication { + if let Some(ak) = &auth.access_key_id { + opts.insert("access_key_id".to_string(), ak.clone()); + } + if let Some(sk) = &auth.secret_access_key { + opts.insert("secret_access_key".to_string(), sk.clone()); + } + if auth.access_key_id.is_none() { + opts.insert("skip_signature".to_string(), "true".to_string()); + } + } else { + opts.insert("skip_signature".to_string(), "true".to_string()); + } + opts + } + "az" | "azure" => { + let mut opts = HashMap::new(); + if let Some(name) = &conn.details.account_name { + opts.insert("account_name".to_string(), name.clone()); + } + if let Some(container) = &conn.details.container_name { + opts.insert("container_name".to_string(), container.clone()); + } + if let Some(ref auth) = conn.authentication { + if let Some(key) = &auth.access_key { + opts.insert("access_key".to_string(), key.clone()); + } + if auth.access_key.is_none() { + opts.insert("skip_signature".to_string(), "true".to_string()); + } + } else { + opts.insert("skip_signature".to_string(), "true".to_string()); + } + opts + } + other => { + return Err(ProxyError::ConfigError(format!( + "unsupported provider '{}' for data connection", + other + ))); + } + }; + + let backend_type = match provider { + "az" | "azure" => "az".to_string(), + other => other.to_string(), + }; + + Ok(BucketConfig { + name: bucket_name, + backend_type, + backend_prefix, + anonymous_access: product.data_mode == "open", + allowed_roles: vec![], + backend_options, + }) + } + + /// Check permissions for an authenticated user via the Source API. + async fn check_permissions( + &self, + headers: &HeaderMap, + account_id: &str, + repo_id: &str, + method: &Method, + ) -> Result<(), ProxyError> { + let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(h) => h, + None => return Ok(()), // Anonymous — skip permission check + }; + + let sig = source_coop_core::auth::parse_sigv4_auth(auth_header)?; + + let perms = self + .api_client + .get_permissions(account_id, repo_id, &sig.access_key_id) + .await? + .ok_or(ProxyError::AccessDenied)?; + + let is_write = matches!(*method, Method::PUT | Method::POST | Method::DELETE); + + if is_write && !perms.write { + tracing::warn!( + account_id = account_id, + repo_id = repo_id, + access_key_id = %sig.access_key_id, + "write permission denied by Source API" + ); + return Err(ProxyError::AccessDenied); + } + + if !is_write && !perms.read { + tracing::warn!( + account_id = account_id, + repo_id = repo_id, + access_key_id = %sig.access_key_id, + "read permission denied by Source API" + ); + return Err(ProxyError::AccessDenied); + } + + Ok(()) + } + + /// Handle `GET /{account_id}` — synthetic account listing. + async fn handle_account_listing(&self, account_id: &str) -> Result { + tracing::info!( + account_id = account_id, + "handling account listing for account" + ); + let account = self + .api_client + .list_account_repos(account_id) + .await? + .ok_or_else(|| ProxyError::BucketNotFound(account_id.to_string()))?; + + tracing::info!( + account_id = account_id, + repo_count = account.products.len(), + "fetched account listing from Source API" + ); + + let prefixes: Vec = account + .products + .iter() + .map(|p| format!("{}/", p.product_id)) + .collect(); + + let xml = synthetic_list_objects_v2_xml(account_id, &prefixes); + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/xml".parse().unwrap()); + + Ok(ResolvedAction::Response { + status: 200, + headers, + body: Bytes::from(xml), + }) + } + + /// Handle a list-with-prefix request where the prefix includes a repo_id. + /// + /// `GET /{account_id}?prefix=repo_id/subdir/...` + async fn handle_list_with_prefix( + &self, + method: &Method, + headers: &HeaderMap, + query: &str, + account_id: &str, + repo_id: &str, + ) -> Result { + let bucket_name = format!("{}/{}", account_id, repo_id); + + // Permission check + self.check_permissions(headers, account_id, repo_id, method) + .await?; + + // Rewrite query: strip `{repo_id}/` from the prefix param + let new_query = rewrite_list_prefix(query, repo_id); + + let bucket_config = self.resolve_bucket_config(account_id, repo_id).await?; + + let operation = build_s3_operation(method, bucket_name, String::new(), Some(&new_query))?; + + // Build list rewrite: strip backend prefix, add repo_id + let list_rewrite = build_list_rewrite(&bucket_config, repo_id); + + Ok(ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite, + }) + } +} + +impl RequestResolver for SourceCoopResolver { + async fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> Result { + let trimmed = path.trim_start_matches('/'); + let segments: Vec<&str> = trimmed.splitn(3, '/').collect(); + + match segments.as_slice() { + // Root: GET / -> empty ListBuckets + [] | [""] => { + let xml = empty_list_buckets_xml(); + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("content-type", "application/xml".parse().unwrap()); + Ok(ResolvedAction::Response { + status: 200, + headers: resp_headers, + body: Bytes::from(xml), + }) + } + + // /{account_id} — either account listing or list-with-prefix + [account_id] if !account_id.is_empty() => { + // Check if there's a prefix query param that starts with a repo_id + if let Some(q) = query { + if let Some(prefix) = extract_query_param(q, "prefix") { + if let Some((repo_part, _rest)) = prefix.split_once('/') { + if !repo_part.is_empty() { + return self + .handle_list_with_prefix( + method, headers, q, account_id, repo_part, + ) + .await; + } + } + } + } + + // No prefix or prefix doesn't contain repo -> synthetic account listing + self.handle_account_listing(account_id).await + } + + // /{account_id}/{repo_id} or /{account_id}/{repo_id}/{key...} + [account_id, repo_id_and_rest @ ..] if !account_id.is_empty() => { + let (repo_id, key) = if repo_id_and_rest.len() == 1 { + (repo_id_and_rest[0], "") + } else { + (repo_id_and_rest[0], repo_id_and_rest[1]) + }; + + if repo_id.is_empty() { + return Err(ProxyError::InvalidRequest("empty repo_id".into())); + } + + let bucket_name = format!("{}/{}", account_id, repo_id); + + // Permission check via Source API + self.check_permissions(headers, account_id, repo_id, method) + .await?; + + // Resolve bucket config + let bucket_config = self.resolve_bucket_config(account_id, repo_id).await?; + + // Build the S3 operation + let operation = build_s3_operation(method, bucket_name, key.to_string(), query)?; + + // For list operations, apply list rewrite + let list_rewrite = if key.is_empty() { + build_list_rewrite(&bucket_config, repo_id) + } else { + None + }; + + Ok(ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite, + }) + } + + _ => Err(ProxyError::InvalidRequest("invalid path".into())), + } + } +} + +// -- Helpers -- + +/// Build a [`ListRewrite`] that strips the backend prefix and prepends `repo_id`. +fn build_list_rewrite(bucket_config: &BucketConfig, repo_id: &str) -> Option { + let strip = bucket_config + .backend_prefix + .as_deref() + .unwrap_or("") + .trim_end_matches('/'); + + if strip.is_empty() && repo_id.is_empty() { + return None; + } + + Some(ListRewrite { + strip_prefix: if strip.is_empty() { + String::new() + } else { + format!("{}/", strip) + }, + add_prefix: repo_id.to_string(), + }) +} + +fn empty_list_buckets_xml() -> String { + r#" + + source-coopsource-coop + +"# + .to_string() +} + +fn synthetic_list_objects_v2_xml(bucket: &str, common_prefixes: &[String]) -> String { + let mut xml = format!( + r#" + + {} + + {} + 1000 + false"#, + bucket, + common_prefixes.len() + ); + + for prefix in common_prefixes { + xml.push_str(&format!( + "\n {}", + prefix + )); + } + + xml.push_str("\n"); + xml +} + +fn extract_query_param(query: &str, name: &str) -> Option { + url::form_urlencoded::parse(query.as_bytes()) + .find(|(k, _)| k == name) + .map(|(_, v)| v.to_string()) +} + +fn rewrite_list_prefix(query: &str, repo_id: &str) -> String { + let params: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| { + if k == "prefix" { + let prefix_to_strip = format!("{}/", repo_id); + let new_v = v.strip_prefix(&prefix_to_strip).unwrap_or(&v).to_string(); + (k.to_string(), new_v) + } else { + (k.to_string(), v.to_string()) + } + }) + .collect(); + + url::form_urlencoded::Serializer::new(String::new()) + .extend_pairs(params.iter()) + .finish() +} diff --git a/crates/libs/core/Cargo.toml b/crates/libs/core/Cargo.toml new file mode 100644 index 0000000..adfe7d3 --- /dev/null +++ b/crates/libs/core/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "source-coop-core" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Runtime-agnostic core library for the S3 proxy gateway" + +[features] +default = [] +axum = ["dep:axum"] +config-dynamodb = ["aws-sdk-dynamodb", "tokio"] +config-postgres = ["sqlx"] +config-http = ["reqwest"] +azure = ["object_store/azure"] +gcp = ["object_store/gcp"] + +[dependencies] +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +toml.workspace = true +bytes.workspace = true +http.workspace = true +chrono.workspace = true +uuid.workspace = true +base64.workspace = true +hex.workspace = true +url.workspace = true +hmac.workspace = true +sha2.workspace = true +aes-gcm.workspace = true +quick-xml.workspace = true +tracing.workspace = true +object_store.workspace = true +futures.workspace = true + +# Optional framework deps +axum = { workspace = true, features = ["json"], optional = true } + +# Optional config backend deps +aws-sdk-dynamodb = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +sqlx = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } diff --git a/crates/libs/core/README.md b/crates/libs/core/README.md new file mode 100644 index 0000000..c9c72b9 --- /dev/null +++ b/crates/libs/core/README.md @@ -0,0 +1,135 @@ +# data.source.coop + +Runtime-agnostic core library for the S3 proxy gateway. This crate contains all business logic — S3 request parsing, SigV4 signing/verification, authorization, configuration retrieval, and the proxy handler — without depending on any async runtime. + +## Why This Crate Exists Separately + +The proxy needs to run on fundamentally different runtimes: Tokio/Hyper in containers and Cloudflare Workers on the edge. These runtimes have incompatible stream types, HTTP primitives, and threading models (multi-threaded vs single-threaded WASM). By keeping the core free of runtime dependencies, it compiles cleanly to both `x86_64-unknown-linux-gnu` and `wasm32-unknown-unknown`. + +## Key Abstractions + +The core defines four trait boundaries that runtime crates implement: + +**`ProxyBackend`** — Provides three capabilities: `create_store()` returns an `ObjectStore` for LIST, `create_signer()` returns a `Signer` for presigned URL generation (GET/HEAD/PUT/DELETE), and `send_raw()` sends signed HTTP requests for multipart operations. Both runtimes delegate to `build_signer()` which uses `object_store`'s built-in signer for authenticated backends and `UnsignedUrlSigner` for anonymous backends (avoiding `Instant::now()` which panics on WASM). For `create_store()`, the server runtime uses default connectors + reqwest; the worker runtime uses a custom `FetchConnector`. + +**`ConfigProvider`** — Retrieves bucket, role, and credential configuration. Ships with four implementations behind feature flags: + +| Provider | Feature | Use Case | +|----------|---------|----------| +| `StaticProvider` | *(always)* | TOML/JSON files, baked-in config | +| `HttpProvider` | `config-http` | Centralized config REST API | +| `DynamoDbProvider` | `config-dynamodb` | AWS-native deployments | +| `PostgresProvider` | `config-postgres` | Database-backed config | + +Any provider can be wrapped with `CachedProvider` for in-memory TTL caching. + +**`RequestResolver`** — Decides what to do with an incoming request. Given an HTTP method, path, query, and headers, a resolver returns a `ResolvedAction`: either forward to a backend (`Proxy`) or return a synthetic response (`Response`). This decouples URL namespace mapping, authentication, and authorization from the proxy handler itself. + +`DefaultResolver` implements the standard S3 proxy flow: parse the S3 operation, look up the bucket in config, authenticate via SigV4, and authorize. Custom resolvers (like the Source Cooperative resolver in `cf-workers`) can implement entirely different routing and auth schemes. + +**`OidcBackendAuth`** — Resolves backend credentials via OIDC token exchange. Called at the top of `dispatch_operation()` before the config reaches `create_store()`/`create_signer()`. When a bucket's `backend_options` contains `auth_type=oidc`, the implementation mints a self-signed JWT and exchanges it for temporary cloud credentials, injecting them into the config. The default `NoOidcAuth` passes configs through unchanged (and errors if `auth_type=oidc` is set without a provider). The `oidc-provider` crate provides `AwsOidcBackendAuth` and `MaybeOidcAuth` as concrete implementations. + +## Module Overview + +``` +src/ +├── auth.rs SigV4 verification, identity resolution, authorization +├── backend.rs ProxyBackend trait, Signer/StoreBuilder, S3RequestSigner (multipart) +├── config/ +│ ├── mod.rs ConfigProvider trait definition +│ ├── cached.rs TTL caching wrapper for any provider +│ ├── static_file.rs TOML/JSON file provider +│ ├── http.rs REST API provider (feature: config-http) +│ ├── dynamodb.rs DynamoDB provider (feature: config-dynamodb) +│ └── postgres.rs PostgreSQL provider (feature: config-postgres) +├── error.rs ProxyError with S3-compatible error codes +├── oidc_backend.rs OidcBackendAuth trait, NoOidcAuth default impl +├── proxy.rs ProxyHandler — the main request handler +├── resolver.rs RequestResolver trait, ResolvedAction, DefaultResolver +├── sealed_token.rs AES-256-GCM encrypted session tokens (TokenKey) +├── s3/ +│ ├── request.rs Parse incoming HTTP → S3Operation enum +│ ├── response.rs Serialize S3 XML responses +│ └── list_rewrite.rs Rewrite / values in list response XML +├── response_body.rs ProxyResponseBody enum (Bytes, Empty) +└── types.rs BucketConfig, RoleConfig, StoredCredential, etc. +``` + +## Usage + +This crate is not used directly. Runtime crates (`source-coop-server`, `source-coop-cf-workers`) depend on it and provide concrete `ProxyBackend` implementations. If you're building a custom runtime integration, depend on this crate and implement `ProxyBackend`, and optionally `ConfigProvider` or `RequestResolver`. + +### Standard usage with a ConfigProvider + +Wrap your config provider in `DefaultResolver` for standard S3 proxy behavior (path/virtual-host parsing, SigV4 auth, scope-based authorization): + +```rust +use source_coop_core::proxy::ProxyHandler; +use source_coop_core::resolver::DefaultResolver; +use source_coop_core::config::static_file::StaticProvider; + +let backend = MyBackend::new(); +let config = StaticProvider::from_file("config.toml")?; +// Optional: enable sealed session tokens for STS temporary credentials. +// When set, TemporaryCredentials are AES-256-GCM encrypted into the session +// token itself — no server-side storage needed (critical for stateless runtimes). +let token_key = None; // or Some(TokenKey::from_base64(&key_b64)?) +let resolver = DefaultResolver::new(config, Some("s3.example.com".into()), token_key); + +let handler = ProxyHandler::new(backend, resolver); +// Optional: enable OIDC-based backend credential resolution. +// let handler = handler.with_oidc_auth(oidc_auth); + +// In your HTTP handler: +let action = handler.resolve_request(method, path, query, &headers).await; +// Handle action: Forward (presigned URL), Response (ProxyResult), or NeedsBody (multipart) +``` + +### Custom resolver + +For non-standard URL namespaces or external auth, implement `RequestResolver` directly: + +```rust +use source_coop_core::resolver::{RequestResolver, ResolvedAction}; +use source_coop_core::error::ProxyError; + +#[derive(Clone)] +struct MyResolver { /* ... */ } + +impl RequestResolver for MyResolver { + async fn resolve( + &self, + method: &http::Method, + path: &str, + query: Option<&str>, + headers: &http::HeaderMap, + ) -> Result { + // Your custom routing, auth, and authorization logic here. + // Return ResolvedAction::Proxy { .. } to forward to a backend, + // or ResolvedAction::Response { .. } for synthetic responses. + todo!() + } +} + +let handler = ProxyHandler::new(backend, MyResolver::new()); +``` + +See `crates/libs/source-coop/src/resolver.rs` for a real-world example that maps a `/{account}/{repo}/{key}` namespace to dynamically-resolved S3 backends with external API authorization. + +## Sealed Session Tokens + +The `sealed_token` module provides stateless temporary credential verification using AES-256-GCM. When a `TokenKey` is configured (via `SESSION_TOKEN_KEY`), the STS handler encrypts the full `TemporaryCredentials` struct into the session token itself. On subsequent requests, `resolve_identity()` decrypts the token to recover the credentials — no server-side storage or config lookup is needed. + +This is critical for stateless runtimes like Cloudflare Workers where in-memory state does not persist across invocations. The `TokenKey` wraps `Arc` and is `Clone + Send + Sync`. + +Token format: `base64url(nonce[12] || ciphertext + tag)`. Expired tokens return `Err(ExpiredCredentials)`. Tokens that fail decryption (wrong key, not a sealed token) return `Ok(None)` allowing graceful rejection. + +Note: because scopes are sealed into the token at mint time, changes to a role's `allowed_scopes` in config only take effect for newly minted credentials — existing tokens retain the scopes they were issued with. + +## Feature Flags + +All optional — the default build has zero network dependencies: + +- `config-http` — enables `HttpProvider` (adds `reqwest`) +- `config-dynamodb` — enables `DynamoDbProvider` (adds `aws-sdk-dynamodb`, `tokio`) +- `config-postgres` — enables `PostgresProvider` (adds `sqlx`) diff --git a/crates/libs/core/src/auth.rs b/crates/libs/core/src/auth.rs new file mode 100644 index 0000000..2dbfbdf --- /dev/null +++ b/crates/libs/core/src/auth.rs @@ -0,0 +1,1127 @@ +//! Authentication and authorization. +//! +//! Handles: +//! - SigV4 request verification (incoming requests from clients) +//! - Identity resolution (mapping access key → principal) +//! - Authorization (checking if an identity can perform an operation) + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::sealed_token::TokenKey; +use crate::types::{Action, ResolvedIdentity, S3Operation}; +use hmac::{Hmac, Mac}; +use http::HeaderMap; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +/// Parsed SigV4 Authorization header. +#[derive(Debug, Clone)] +pub struct SigV4Auth { + pub access_key_id: String, + pub date_stamp: String, + pub region: String, + pub service: String, + pub signed_headers: Vec, + pub signature: String, +} + +/// Parse a SigV4 Authorization header. +/// +/// Format: `AWS4-HMAC-SHA256 Credential=AKID/20240101/us-east-1/s3/aws4_request, +/// SignedHeaders=host;x-amz-date, Signature=abcdef...` +pub fn parse_sigv4_auth(auth_header: &str) -> Result { + let auth_header = auth_header + .strip_prefix("AWS4-HMAC-SHA256 ") + .ok_or_else(|| ProxyError::InvalidRequest("invalid auth scheme".into()))?; + + let mut credential = None; + let mut signed_headers = None; + let mut signature = None; + + for part in auth_header.split(", ") { + if let Some(val) = part.strip_prefix("Credential=") { + credential = Some(val); + } else if let Some(val) = part.strip_prefix("SignedHeaders=") { + signed_headers = Some(val); + } else if let Some(val) = part.strip_prefix("Signature=") { + signature = Some(val); + } + } + + let credential = + credential.ok_or_else(|| ProxyError::InvalidRequest("missing Credential".into()))?; + let signed_headers = + signed_headers.ok_or_else(|| ProxyError::InvalidRequest("missing SignedHeaders".into()))?; + let signature = + signature.ok_or_else(|| ProxyError::InvalidRequest("missing Signature".into()))?; + + // Parse credential: AKID/date/region/service/aws4_request + let cred_parts: Vec<&str> = credential.split('/').collect(); + if cred_parts.len() != 5 || cred_parts[4] != "aws4_request" { + return Err(ProxyError::InvalidRequest( + "malformed credential scope".into(), + )); + } + + Ok(SigV4Auth { + access_key_id: cred_parts[0].to_string(), + date_stamp: cred_parts[1].to_string(), + region: cred_parts[2].to_string(), + service: cred_parts[3].to_string(), + signed_headers: signed_headers.split(';').map(String::from).collect(), + signature: signature.to_string(), + }) +} + +/// Verify a SigV4 signature against a known secret key. +pub fn verify_sigv4_signature( + method: &http::Method, + uri_path: &str, + query_string: &str, + headers: &HeaderMap, + auth: &SigV4Auth, + secret_access_key: &str, + payload_hash: &str, +) -> Result { + // Reconstruct canonical request + let canonical_headers: String = auth + .signed_headers + .iter() + .map(|name| { + let value = headers + .get(name.as_str()) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .trim(); + format!("{}:{}\n", name, value) + }) + .collect(); + + let signed_headers_str = auth.signed_headers.join(";"); + + // SigV4 requires query parameters sorted alphabetically by key (then value). + // The raw query string from the URL may not be sorted, but the client SDK + // sorts them when constructing the canonical request for signing. + let canonical_query = canonicalize_query_string(query_string); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method, uri_path, canonical_query, canonical_headers, signed_headers_str, payload_hash + ); + + let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); + + let credential_scope = format!( + "{}/{}/{}/aws4_request", + auth.date_stamp, auth.region, auth.service + ); + + let amz_date = headers + .get("x-amz-date") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, credential_scope, canonical_request_hash + ); + + // Derive signing key + let k_date = hmac_sha256( + format!("AWS4{}", secret_access_key).as_bytes(), + auth.date_stamp.as_bytes(), + )?; + let k_region = hmac_sha256(&k_date, auth.region.as_bytes())?; + let k_service = hmac_sha256(&k_region, auth.service.as_bytes())?; + let signing_key = hmac_sha256(&k_service, b"aws4_request")?; + + let expected_signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes())?); + + let matched = constant_time_eq(expected_signature.as_bytes(), auth.signature.as_bytes()); + + if !matched { + tracing::warn!( + access_key_id = %auth.access_key_id, + region = %auth.region, + "SigV4 signature mismatch" + ); + tracing::debug!( + canonical_request = %canonical_request, + string_to_sign = %string_to_sign, + "SigV4 signature mismatch details — compare canonical_request with client-side (aws --debug)" + ); + } + + Ok(matched) +} + +/// Sort query string parameters for SigV4 canonical request construction. +fn canonicalize_query_string(query: &str) -> String { + if query.is_empty() { + return String::new(); + } + let mut parts: Vec<&str> = query.split('&').collect(); + parts.sort_unstable(); + parts.join("&") +} + +fn hmac_sha256(key: &[u8], data: &[u8]) -> Result, ProxyError> { + let mut mac = + HmacSha256::new_from_slice(key).map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(data); + Ok(mac.finalize().into_bytes().to_vec()) +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b.iter()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Resolve the identity of an incoming request. +/// +/// Parses the SigV4 Authorization header, looks up the credential, verifies +/// the signature, and returns the resolved identity. +pub async fn resolve_identity( + method: &http::Method, + uri_path: &str, + query_string: &str, + headers: &HeaderMap, + config: &C, + token_key: Option<&TokenKey>, +) -> Result { + let auth_header = match headers.get("authorization").and_then(|v| v.to_str().ok()) { + Some(h) => h, + None => return Ok(ResolvedIdentity::Anonymous), + }; + + let sig = parse_sigv4_auth(auth_header)?; + + // The payload hash is sent by the client in x-amz-content-sha256. + // For streaming uploads this is the UNSIGNED-PAYLOAD or + // STREAMING-AWS4-HMAC-SHA256-PAYLOAD sentinel — both are valid + // canonical-request inputs per the SigV4 spec. + let payload_hash = headers + .get("x-amz-content-sha256") + .and_then(|v| v.to_str().ok()) + .unwrap_or("UNSIGNED-PAYLOAD"); + + // Temporary credentials: decrypt the session token to recover credentials + if let Some(session_token) = headers + .get("x-amz-security-token") + .and_then(|v| v.to_str().ok()) + { + let key = token_key.ok_or_else(|| { + tracing::warn!("session token present but no token_key configured"); + ProxyError::AccessDenied + })?; + + match key.unseal(session_token)? { + Some(creds) => { + if !constant_time_eq(sig.access_key_id.as_bytes(), creds.access_key_id.as_bytes()) { + tracing::warn!( + header_key = %sig.access_key_id, + sealed_key = %creds.access_key_id, + "access key mismatch between auth header and sealed token" + ); + return Err(ProxyError::AccessDenied); + } + if !verify_sigv4_signature( + method, + uri_path, + query_string, + headers, + &sig, + &creds.secret_access_key, + payload_hash, + )? { + return Err(ProxyError::SignatureDoesNotMatch); + } + tracing::debug!( + access_key = %creds.access_key_id, + role = %creds.assumed_role_id, + scopes = ?creds.allowed_scopes, + "sealed token identity resolved" + ); + return Ok(ResolvedIdentity::Temporary { credentials: creds }); + } + None => { + tracing::warn!( + access_key_id = %sig.access_key_id, + token_len = session_token.len(), + "session token could not be unsealed — possible key mismatch, token corruption, or expired key rotation" + ); + return Err(ProxyError::AccessDenied); + } + } + } + + // Check long-lived credentials + if let Some(cred) = config.get_credential(&sig.access_key_id).await? { + if !cred.enabled { + return Err(ProxyError::AccessDenied); + } + if let Some(expires) = cred.expires_at { + if expires <= chrono::Utc::now() { + return Err(ProxyError::ExpiredCredentials); + } + } + + // Verify SigV4 signature + if !verify_sigv4_signature( + method, + uri_path, + query_string, + headers, + &sig, + &cred.secret_access_key, + payload_hash, + )? { + return Err(ProxyError::SignatureDoesNotMatch); + } + + return Ok(ResolvedIdentity::LongLived { credential: cred }); + } + + Err(ProxyError::AccessDenied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ + AccessScope, Action, BucketConfig, RoleConfig, StoredCredential, TemporaryCredentials, + }; + + // ── Mock config provider ────────────────────────────────────────── + + #[derive(Clone)] + struct MockConfig { + credentials: Vec, + } + + impl MockConfig { + fn with_credential(secret: &str) -> Self { + Self { + credentials: vec![StoredCredential { + access_key_id: "AKIAIOSFODNN7EXAMPLE".into(), + secret_access_key: secret.into(), + principal_name: "test-user".into(), + allowed_scopes: vec![AccessScope { + bucket: "test-bucket".into(), + prefixes: vec![], + actions: vec![Action::GetObject], + }], + created_at: chrono::Utc::now(), + expires_at: None, + enabled: true, + }], + } + } + + fn empty() -> Self { + Self { + credentials: vec![], + } + } + } + + impl crate::config::ConfigProvider for MockConfig { + async fn list_buckets(&self) -> Result, ProxyError> { + Ok(vec![]) + } + async fn get_bucket(&self, _: &str) -> Result, ProxyError> { + Ok(None) + } + async fn get_role(&self, _: &str) -> Result, ProxyError> { + Ok(None) + } + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + Ok(self + .credentials + .iter() + .find(|c| c.access_key_id == access_key_id) + .cloned()) + } + } + + // ── Test signing helper ─────────────────────────────────────────── + + /// Build a valid SigV4 Authorization header value for testing. + fn sign_request( + method: &http::Method, + uri_path: &str, + query_string: &str, + headers: &HeaderMap, + access_key_id: &str, + secret_access_key: &str, + date_stamp: &str, + amz_date: &str, + region: &str, + signed_header_names: &[&str], + payload_hash: &str, + ) -> String { + let canonical_headers: String = signed_header_names + .iter() + .map(|name| { + let value = headers + .get(*name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .trim(); + format!("{}:{}\n", name, value) + }) + .collect(); + + let signed_headers_str = signed_header_names.join(";"); + + // AWS SDKs sort query parameters when constructing the canonical request + let canonical_query = canonicalize_query_string(query_string); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method, uri_path, canonical_query, canonical_headers, signed_headers_str, payload_hash + ); + + let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); + let credential_scope = format!("{}/{}/s3/aws4_request", date_stamp, region); + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, credential_scope, canonical_request_hash + ); + + let k_date = hmac_sha256( + format!("AWS4{}", secret_access_key).as_bytes(), + date_stamp.as_bytes(), + ) + .unwrap(); + let k_region = hmac_sha256(&k_date, region.as_bytes()).unwrap(); + let k_service = hmac_sha256(&k_region, b"s3").unwrap(); + let signing_key = hmac_sha256(&k_service, b"aws4_request").unwrap(); + let signature = hex::encode(hmac_sha256(&signing_key, string_to_sign.as_bytes()).unwrap()); + + format!( + "AWS4-HMAC-SHA256 Credential={}/{}/{}/s3/aws4_request, SignedHeaders={}, Signature={}", + access_key_id, date_stamp, region, signed_headers_str, signature + ) + } + + /// Build headers and auth for a simple GET request. + fn make_signed_headers(access_key_id: &str, secret_access_key: &str) -> HeaderMap { + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let region = "us-east-1"; + let payload_hash = "UNSIGNED-PAYLOAD"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + + let auth = sign_request( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + access_key_id, + secret_access_key, + date_stamp, + amz_date, + region, + &["host", "x-amz-content-sha256", "x-amz-date"], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + headers + } + + // ── Tests ───────────────────────────────────────────────────────── + + fn run(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn no_auth_header_returns_anonymous() { + run(async { + let headers = HeaderMap::new(); + let config = MockConfig::empty(); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap(); + + assert!(matches!(identity, ResolvedIdentity::Anonymous)); + }); + } + + #[test] + fn valid_signature_resolves_identity() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let config = MockConfig::with_credential(secret); + let headers = make_signed_headers("AKIAIOSFODNN7EXAMPLE", secret); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap(); + + assert!(matches!(identity, ResolvedIdentity::LongLived { .. })); + }); + } + + #[test] + fn valid_signature_with_unsorted_query_params() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let config = MockConfig::with_credential(secret); + + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let payload_hash = "UNSIGNED-PAYLOAD"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + + // Sign with sorted query (as AWS SDKs do internally) + let auth = sign_request( + &http::Method::GET, + "/test-bucket", + "list-type=2&prefix=&delimiter=%2F&encoding-type=url", + &headers, + "AKIAIOSFODNN7EXAMPLE", + secret, + date_stamp, + amz_date, + "us-east-1", + &["host", "x-amz-content-sha256", "x-amz-date"], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + + // Pass UNSORTED query string (as it arrives from the raw URL) + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket", + "list-type=2&prefix=&delimiter=%2F&encoding-type=url", + &headers, + &config, + None, + ) + .await + .unwrap(); + + assert!(matches!(identity, ResolvedIdentity::LongLived { .. })); + }); + } + + #[test] + fn wrong_signature_is_rejected() { + run(async { + let real_secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let wrong_secret = "WRONGSECRETKEYWRONGSECRETSECRET00000000000"; + let config = MockConfig::with_credential(real_secret); + // Sign with wrong secret — access_key_id is correct, signature won't match + let headers = make_signed_headers("AKIAIOSFODNN7EXAMPLE", wrong_secret); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!( + matches!(err, ProxyError::SignatureDoesNotMatch), + "expected SignatureDoesNotMatch, got: {:?}", + err + ); + }); + } + + #[test] + fn garbage_signature_is_rejected() { + run(async { + let real_secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let config = MockConfig::with_credential(real_secret); + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", "20240101T000000Z".parse().unwrap()); + headers.insert("x-amz-content-sha256", "UNSIGNED-PAYLOAD".parse().unwrap()); + headers.insert( + "authorization", + "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20240101/us-east-1/s3/aws4_request, \ + SignedHeaders=host;x-amz-content-sha256;x-amz-date, \ + Signature=0000000000000000000000000000000000000000000000000000000000000000" + .parse() + .unwrap(), + ); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::SignatureDoesNotMatch)); + }); + } + + #[test] + fn unknown_access_key_is_rejected() { + run(async { + let config = MockConfig::empty(); + let headers = make_signed_headers("AKIAUNKNOWN000000000", "some-secret"); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::AccessDenied)); + }); + } + + #[test] + fn sealed_token_wrong_session_token_is_rejected() { + use crate::sealed_token::TokenKey; + + run(async { + let key_bytes = [0x42u8; 32]; + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key_bytes); + let token_key = TokenKey::from_base64(&encoded).unwrap(); + let config = MockConfig::empty(); + + let secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let wrong_token = "NOT_A_SEALED_TOKEN_AT_ALL"; + + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let payload_hash = "UNSIGNED-PAYLOAD"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + headers.insert("x-amz-security-token", wrong_token.parse().unwrap()); + + let auth = sign_request( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + "ASIATEMP1234EXAMPLE", + secret, + date_stamp, + amz_date, + "us-east-1", + &[ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + ], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + Some(&token_key), + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::AccessDenied)); + }); + } + + #[test] + fn sealed_token_wrong_signature_is_rejected() { + use crate::sealed_token::TokenKey; + use crate::types::AccessScope; + + run(async { + let key_bytes = [0x42u8; 32]; + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key_bytes); + let token_key = TokenKey::from_base64(&encoded).unwrap(); + + let real_secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let wrong_secret = "WRONGSECRETKEYWRONGSECRETSECRET00000000000"; + let creds = TemporaryCredentials { + access_key_id: "ASIATEMP1234EXAMPLE".into(), + secret_access_key: real_secret.into(), + session_token: String::new(), + expiration: chrono::Utc::now() + chrono::Duration::hours(1), + allowed_scopes: vec![AccessScope { + bucket: "test-bucket".into(), + prefixes: vec![], + actions: vec![Action::GetObject], + }], + assumed_role_id: "role-1".into(), + source_identity: "test".into(), + }; + + let sealed = token_key.seal(&creds).unwrap(); + let config = MockConfig::empty(); + + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let payload_hash = "UNSIGNED-PAYLOAD"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + headers.insert("x-amz-security-token", sealed.parse().unwrap()); + + // Sign with wrong secret — sealed token is valid but sig won't match + let auth = sign_request( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + "ASIATEMP1234EXAMPLE", + wrong_secret, + date_stamp, + amz_date, + "us-east-1", + &[ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + ], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + Some(&token_key), + ) + .await + .unwrap_err(); + + assert!( + matches!(err, ProxyError::SignatureDoesNotMatch), + "expected SignatureDoesNotMatch, got: {:?}", + err + ); + }); + } + + #[test] + fn disabled_credential_is_rejected_before_sig_check() { + run(async { + let secret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; + let mut config = MockConfig::with_credential(secret); + config.credentials[0].enabled = false; + + let headers = make_signed_headers("AKIAIOSFODNN7EXAMPLE", secret); + + let err = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + None, + ) + .await + .unwrap_err(); + + assert!(matches!(err, ProxyError::AccessDenied)); + }); + } + + // ── SigV4 spec compliance tests ────────────────────────────────── + + /// Validate our SigV4 implementation against the official AWS test suite. + /// Test vector: "get-vanilla" from + /// https://docs.aws.amazon.com/general/latest/gr/signature-v4-test-suite.html + #[test] + fn sigv4_test_vector_get_vanilla() { + let access_key_id = "AKIDEXAMPLE"; + let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + let date_stamp = "20150830"; + let amz_date = "20150830T123600Z"; + let region = "us-east-1"; + let service = "service"; + let payload_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "example.amazonaws.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + + // Build the canonical request exactly as the spec defines: + // GET\n/\n\nhost:example.amazonaws.com\nx-amz-date:20150830T123600Z\n\nhost;x-amz-date\ne3b0c44... + let auth = SigV4Auth { + access_key_id: access_key_id.to_string(), + date_stamp: date_stamp.to_string(), + region: region.to_string(), + service: service.to_string(), + signed_headers: vec!["host".to_string(), "x-amz-date".to_string()], + signature: "5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31" + .to_string(), + }; + + let result = verify_sigv4_signature( + &http::Method::GET, + "/", + "", + &headers, + &auth, + secret, + payload_hash, + ) + .unwrap(); + + assert!(result, "AWS SigV4 test vector 'get-vanilla' must pass"); + } + + /// Test vector: "get-vanilla-query-order-key" — verifies query parameter sorting. + /// Parameters Param2 and Param1 must be sorted alphabetically. + #[test] + fn sigv4_test_vector_query_order() { + let secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY"; + let date_stamp = "20150830"; + let amz_date = "20150830T123600Z"; + let payload_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "example.amazonaws.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + + let auth = SigV4Auth { + access_key_id: "AKIDEXAMPLE".to_string(), + date_stamp: date_stamp.to_string(), + region: "us-east-1".to_string(), + service: "service".to_string(), + signed_headers: vec!["host".to_string(), "x-amz-date".to_string()], + signature: "b97d918cfa904a5beff61c982a1b6f458b799221646efd99d3219ec94cdf2500" + .to_string(), + }; + + // Pass UNSORTED query — our canonicalization should sort to Param1=value1&Param2=value2 + let result = verify_sigv4_signature( + &http::Method::GET, + "/", + "Param2=value2&Param1=value1", + &headers, + &auth, + secret, + payload_hash, + ) + .unwrap(); + + assert!( + result, + "AWS SigV4 test vector 'get-vanilla-query-order-key' must pass" + ); + } + + /// Realistic S3 ListObjectsV2 request with host:port, security token, + /// and unsorted query parameters — mirrors what `aws s3 ls` sends. + #[test] + fn sigv4_list_objects_with_security_token_and_port() { + let secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let session_token = "FwoGZXIvYXdzEBYaDGFiY2RlZjEyMzQ1Ng"; + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let payload_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "localhost:8787".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + headers.insert("x-amz-security-token", session_token.parse().unwrap()); + + // Sign with sorted query (as AWS SDKs do) + let auth = sign_request( + &http::Method::GET, + "/private-uploads", + "list-type=2&prefix=&delimiter=%2F&encoding-type=url", + &headers, + "ASIATEMP1234EXAMPLE", + secret, + date_stamp, + amz_date, + "us-east-1", + &[ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + ], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + + // Verify with UNSORTED query (as it arrives from the raw URL) + let sig = + parse_sigv4_auth(headers.get("authorization").unwrap().to_str().unwrap()).unwrap(); + + let result = verify_sigv4_signature( + &http::Method::GET, + "/private-uploads", + "list-type=2&prefix=&delimiter=%2F&encoding-type=url", + &headers, + &sig, + secret, + payload_hash, + ) + .unwrap(); + + assert!( + result, + "S3 ListObjects with security token and host:port must verify" + ); + } + + // ── Sealed token tests ────────────────────────────────────────── + + #[test] + fn sealed_token_round_trip() { + use crate::sealed_token::TokenKey; + use crate::types::AccessScope; + + run(async { + let key_bytes = [0x42u8; 32]; + let encoded = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, key_bytes); + let token_key = TokenKey::from_base64(&encoded).unwrap(); + + let secret = "TempSecretKey1234567890EXAMPLE000000000000"; + let creds = TemporaryCredentials { + access_key_id: "ASIATEMP1234EXAMPLE".into(), + secret_access_key: secret.into(), + session_token: String::new(), // will be replaced by seal + expiration: chrono::Utc::now() + chrono::Duration::hours(1), + allowed_scopes: vec![AccessScope { + bucket: "test-bucket".into(), + prefixes: vec![], + actions: vec![Action::GetObject], + }], + assumed_role_id: "role-1".into(), + source_identity: "test".into(), + }; + + let sealed = token_key.seal(&creds).unwrap(); + let config = MockConfig::empty(); + + let date_stamp = "20240101"; + let amz_date = "20240101T000000Z"; + let payload_hash = "UNSIGNED-PAYLOAD"; + + let mut headers = HeaderMap::new(); + headers.insert("host", "s3.example.com".parse().unwrap()); + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + headers.insert("x-amz-security-token", sealed.parse().unwrap()); + + let auth = sign_request( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + "ASIATEMP1234EXAMPLE", + secret, + date_stamp, + amz_date, + "us-east-1", + &[ + "host", + "x-amz-content-sha256", + "x-amz-date", + "x-amz-security-token", + ], + payload_hash, + ); + headers.insert("authorization", auth.parse().unwrap()); + + let identity = resolve_identity( + &http::Method::GET, + "/test-bucket/key.txt", + "", + &headers, + &config, + Some(&token_key), + ) + .await + .unwrap(); + + assert!(matches!(identity, ResolvedIdentity::Temporary { .. })); + }); + } + + // ── Prefix boundary tests ───────────────────────────────────── + + #[test] + fn prefix_with_slash_matches_children() { + assert!(key_matches_prefix("data/file.txt", "data/")); + assert!(key_matches_prefix("data/sub/file.txt", "data/")); + } + + #[test] + fn prefix_without_slash_enforces_boundary() { + // Should match exact or with / boundary + assert!(key_matches_prefix("data/file.txt", "data")); + assert!(key_matches_prefix("data", "data")); + // Should NOT match sibling paths + assert!(!key_matches_prefix("data-private/secret.txt", "data")); + assert!(!key_matches_prefix("database/dump.sql", "data")); + } + + #[test] + fn empty_prefix_matches_everything() { + assert!(key_matches_prefix("anything/at/all.txt", "")); + assert!(key_matches_prefix("", "")); + } + + #[test] + fn prefix_no_match() { + assert!(!key_matches_prefix("other/file.txt", "data/")); + assert!(!key_matches_prefix("other/file.txt", "data")); + } +} + +/// Check if a key falls under an authorized prefix. +/// +/// If the prefix already ends with `/`, a plain `starts_with` is sufficient. +/// Otherwise we require that the key either equals the prefix exactly or +/// that the character immediately after the prefix is `/`. This prevents +/// a prefix like `data` from matching `data-private/secret.txt`. +fn key_matches_prefix(key: &str, prefix: &str) -> bool { + if prefix.ends_with('/') || prefix.is_empty() { + return key.starts_with(prefix); + } + // Prefix does not end with '/' — require an exact match or a '/' boundary + key == prefix || key.starts_with(&format!("{}/", prefix)) +} + +/// Check if a resolved identity is authorized to perform an operation. +pub fn authorize( + identity: &ResolvedIdentity, + operation: &S3Operation, + bucket_config: &crate::types::BucketConfig, +) -> Result<(), ProxyError> { + // Anonymous access check + if matches!(identity, ResolvedIdentity::Anonymous) { + if bucket_config.anonymous_access { + // Anonymous users can only read + let action = operation.action(); + if matches!( + action, + Action::GetObject | Action::HeadObject | Action::ListBucket + ) { + return Ok(()); + } + } + return Err(ProxyError::AccessDenied); + } + + let scopes = match identity { + ResolvedIdentity::Anonymous => unreachable!(), + ResolvedIdentity::LongLived { credential } => &credential.allowed_scopes, + ResolvedIdentity::Temporary { credentials } => &credentials.allowed_scopes, + }; + + let action = operation.action(); + let bucket = operation.bucket().unwrap_or_default().to_string(); + let key = match operation { + S3Operation::ListBucket { raw_query, .. } => { + // Extract prefix from raw query for authorization checks + raw_query + .as_deref() + .and_then(|q| { + url::form_urlencoded::parse(q.as_bytes()) + .find(|(k, _)| k == "prefix") + .map(|(_, v)| v.to_string()) + }) + .unwrap_or_default() + } + _ => operation.key().to_string(), + }; + + // Check if any scope grants access + let authorized = scopes.iter().any(|scope| { + if scope.bucket != bucket { + return false; + } + if !scope.actions.contains(&action) { + return false; + } + // Check prefix restrictions + if scope.prefixes.is_empty() { + return true; // Full bucket access + } + scope + .prefixes + .iter() + .any(|prefix| key_matches_prefix(&key, prefix)) + }); + + if authorized { + Ok(()) + } else { + tracing::warn!( + action = ?action, + bucket = %bucket, + key = %key, + scopes = ?scopes, + "authorization denied — no scope grants access" + ); + Err(ProxyError::AccessDenied) + } +} diff --git a/crates/libs/core/src/axum.rs b/crates/libs/core/src/axum.rs new file mode 100644 index 0000000..49b4c62 --- /dev/null +++ b/crates/libs/core/src/axum.rs @@ -0,0 +1,33 @@ +//! Axum response helpers shared across runtimes. +//! +//! Gated behind the `axum` feature flag so the core crate remains usable +//! without pulling in axum. + +use ::axum::body::Body; +use ::axum::response::Response; + +use crate::proxy::ProxyResult; +use crate::response_body::ProxyResponseBody; + +/// Convert a [`ProxyResult`] to an axum [`Response`]. +pub fn build_proxy_response(result: ProxyResult) -> Response { + let body = match result.body { + ProxyResponseBody::Bytes(b) => Body::from(b), + ProxyResponseBody::Empty => Body::empty(), + }; + + let mut builder = Response::builder().status(result.status); + for (key, value) in result.headers.iter() { + builder = builder.header(key, value); + } + + builder.body(body).unwrap() +} + +/// Build a plain-text error response. +pub fn error_response(status: u16, message: &str) -> Response { + Response::builder() + .status(status) + .body(Body::from(message.to_string())) + .unwrap() +} diff --git a/crates/libs/core/src/backend.rs b/crates/libs/core/src/backend.rs new file mode 100644 index 0000000..5f5a384 --- /dev/null +++ b/crates/libs/core/src/backend.rs @@ -0,0 +1,471 @@ +//! Backend abstraction for proxying requests to backing object stores. +//! +//! [`ProxyBackend`] is the main trait runtimes implement. It provides three +//! capabilities: +//! +//! 1. **`create_paginated_store()`** — build a `PaginatedListStore` for LIST +//! operations with backend-side pagination. +//! 2. **`create_signer()`** — build a `Signer` for generating presigned URLs +//! for GET, HEAD, PUT, DELETE operations. +//! 3. **`send_raw()`** — send a pre-signed HTTP request for operations not +//! covered by `ObjectStore` (multipart uploads). +//! +//! [`S3RequestSigner`] is retained for signing multipart requests. +//! [`build_paginated_list_store`] and [`build_signer`] dispatch on +//! `BucketConfig::backend_type` to build the appropriate provider. +//! [`build_signer`] uses `object_store`'s built-in signer for authenticated +//! backends, and [`UnsignedUrlSigner`] for anonymous backends (avoiding +//! `Instant::now()` which panics on WASM). + +use crate::error::ProxyError; +use crate::maybe_send::{MaybeSend, MaybeSync}; +use crate::types::{BackendType, BucketConfig}; +use bytes::Bytes; +use http::HeaderMap; +use object_store::aws::AmazonS3Builder; +use object_store::list::PaginatedListStore; +use object_store::signer::Signer; +use object_store::ObjectStore; +use std::future::Future; +use std::sync::Arc; + +#[cfg(feature = "azure")] +use object_store::azure::MicrosoftAzureBuilder; +#[cfg(feature = "gcp")] +use object_store::gcp::GoogleCloudStorageBuilder; + +/// Trait for runtime-specific backend operations. +/// +/// Each runtime provides its own implementation: +/// - Server runtime: uses `reqwest` for raw HTTP, default `object_store` HTTP connector +/// - Worker runtime: uses `web_sys::fetch` for raw HTTP, custom `FetchConnector` for `object_store` +pub trait ProxyBackend: Clone + MaybeSend + MaybeSync + 'static { + /// Create a [`PaginatedListStore`] for the given bucket configuration. + /// + /// Used for LIST operations with backend-side pagination via + /// [`PaginatedListStore::list_paginated`], avoiding loading all results + /// into memory. + fn create_paginated_store( + &self, + config: &BucketConfig, + ) -> Result, ProxyError>; + + /// Create a `Signer` for generating presigned URLs. + /// + /// Used for GET, HEAD, PUT, DELETE operations. The handler generates + /// a presigned URL and the runtime executes the request with its + /// native HTTP client, enabling zero-copy streaming. + fn create_signer(&self, config: &BucketConfig) -> Result, ProxyError>; + + /// Send a raw HTTP request (used for multipart operations that + /// `ObjectStore` doesn't expose at the right abstraction level). + fn send_raw( + &self, + method: http::Method, + url: String, + headers: HeaderMap, + body: Bytes, + ) -> impl Future> + MaybeSend; +} + +/// Response from a raw HTTP request to a backend. +pub struct RawResponse { + pub status: u16, + pub headers: HeaderMap, + pub body: Bytes, +} + +/// Wrapper around provider-specific `object_store` builders. +/// +/// Runtimes use [`build_object_store`] and inject their HTTP connector +/// via a closure that receives this enum. +pub enum StoreBuilder { + S3(AmazonS3Builder), + #[cfg(feature = "azure")] + Azure(MicrosoftAzureBuilder), + #[cfg(feature = "gcp")] + Gcs(GoogleCloudStorageBuilder), +} + +impl StoreBuilder { + /// Build the final `ObjectStore`. + pub fn build(self) -> Result, ProxyError> { + match self { + StoreBuilder::S3(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build S3 store: {}", e)) + })?)), + #[cfg(feature = "azure")] + StoreBuilder::Azure(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build Azure store: {}", e)) + })?)), + #[cfg(feature = "gcp")] + StoreBuilder::Gcs(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build GCS store: {}", e)) + })?)), + } + } + + /// Build a `PaginatedListStore` for backend-side paginated listing. + pub fn build_paginated(self) -> Result, ProxyError> { + match self { + StoreBuilder::S3(b) => Ok(Box::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build S3 paginated store: {}", e)) + })?)), + #[cfg(feature = "azure")] + StoreBuilder::Azure(b) => Ok(Box::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build Azure paginated store: {}", e)) + })?)), + #[cfg(feature = "gcp")] + StoreBuilder::Gcs(b) => Ok(Box::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build GCS paginated store: {}", e)) + })?)), + } + } + + /// Build a `Signer` for presigned URL generation. + pub fn build_signer(self) -> Result, ProxyError> { + match self { + StoreBuilder::S3(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build S3 signer: {}", e)) + })?)), + #[cfg(feature = "azure")] + StoreBuilder::Azure(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build Azure signer: {}", e)) + })?)), + #[cfg(feature = "gcp")] + StoreBuilder::Gcs(b) => Ok(Arc::new(b.build().map_err(|e| { + ProxyError::ConfigError(format!("failed to build GCS signer: {}", e)) + })?)), + } + } +} + +/// Create a [`StoreBuilder`] from a [`BucketConfig`], dispatching on `backend_type`. +fn create_builder(config: &BucketConfig) -> Result { + let backend_type = config.parsed_backend_type().ok_or_else(|| { + ProxyError::ConfigError(format!( + "unsupported backend_type: '{}'", + config.backend_type + )) + })?; + + match backend_type { + BackendType::S3 => { + let mut b = AmazonS3Builder::new(); + for (k, v) in &config.backend_options { + if let Ok(key) = k.parse() { + b = b.with_config(key, v); + } + } + Ok(StoreBuilder::S3(b)) + } + #[cfg(feature = "azure")] + BackendType::Azure => { + let mut b = MicrosoftAzureBuilder::new(); + for (k, v) in &config.backend_options { + if let Ok(key) = k.parse() { + b = b.with_config(key, v); + } + } + Ok(StoreBuilder::Azure(b)) + } + #[cfg(not(feature = "azure"))] + BackendType::Azure => Err(ProxyError::ConfigError( + "Azure backend support not enabled (requires 'azure' feature)".into(), + )), + #[cfg(feature = "gcp")] + BackendType::Gcs => { + let mut b = GoogleCloudStorageBuilder::new(); + for (k, v) in &config.backend_options { + if let Ok(key) = k.parse() { + b = b.with_config(key, v); + } + } + Ok(StoreBuilder::Gcs(b)) + } + #[cfg(not(feature = "gcp"))] + BackendType::Gcs => Err(ProxyError::ConfigError( + "GCS backend support not enabled (requires 'gcp' feature)".into(), + )), + } +} + +/// Build an `ObjectStore` from a [`BucketConfig`], dispatching on `backend_type`. +/// +/// The `configure` closure lets each runtime inject its HTTP connector: +/// - Server runtime passes `|b| b` (default connector) +/// - CF Workers passes `|b| match b { StoreBuilder::S3(s) => StoreBuilder::S3(s.with_http_connector(FetchConnector)), .. }` +pub fn build_object_store( + config: &BucketConfig, + configure: F, +) -> Result, ProxyError> +where + F: FnOnce(StoreBuilder) -> StoreBuilder, +{ + configure(create_builder(config)?).build() +} + +/// Build a [`PaginatedListStore`] from a [`BucketConfig`], dispatching on `backend_type`. +/// +/// Like [`build_object_store`], accepts a configure closure for HTTP connector injection. +pub fn build_paginated_list_store( + config: &BucketConfig, + configure: F, +) -> Result, ProxyError> +where + F: FnOnce(StoreBuilder) -> StoreBuilder, +{ + configure(create_builder(config)?).build_paginated() +} + +/// Build a [`Signer`] from a [`BucketConfig`], dispatching on `backend_type`. +/// +/// For backends with credentials, uses `object_store`'s built-in signer +/// (WASM-safe because `StaticCredentialProvider` bypasses `Instant::now()`). +/// For anonymous backends (no credentials), returns [`UnsignedUrlSigner`] +/// which constructs plain URLs without auth parameters, avoiding the +/// `InstanceCredentialProvider` → `Instant::now()` panic on WASM. +pub fn build_signer(config: &BucketConfig) -> Result, ProxyError> { + let backend_type = config.parsed_backend_type().ok_or_else(|| { + ProxyError::ConfigError(format!( + "unsupported backend_type: '{}'", + config.backend_type + )) + })?; + + // Check for credentials — if absent, return unsigned signer to avoid + // InstanceCredentialProvider which uses Instant::now() (panics on WASM). + let has_creds = !config.option("access_key_id").unwrap_or("").is_empty() + && !config.option("secret_access_key").unwrap_or("").is_empty(); + + if !has_creds { + return Ok(Arc::new(UnsignedUrlSigner::from_config(config)?)); + } + + match backend_type { + BackendType::S3 => create_builder(config)?.build_signer(), + #[cfg(feature = "azure")] + BackendType::Azure => create_builder(config)?.build_signer(), + #[cfg(not(feature = "azure"))] + BackendType::Azure => Err(ProxyError::ConfigError( + "Azure backend support not enabled (requires 'azure' feature)".into(), + )), + #[cfg(feature = "gcp")] + BackendType::Gcs => create_builder(config)?.build_signer(), + #[cfg(not(feature = "gcp"))] + BackendType::Gcs => Err(ProxyError::ConfigError( + "GCS backend support not enabled (requires 'gcp' feature)".into(), + )), + } +} + +/// Helper to build a signed URL + headers for an outbound request to S3. +/// +/// Used for multipart operations (CreateMultipartUpload, UploadPart, +/// CompleteMultipartUpload, AbortMultipartUpload) that go through raw HTTP. +pub struct S3RequestSigner { + pub access_key_id: String, + pub secret_access_key: String, + pub region: String, + pub service: String, + pub session_token: Option, +} + +impl S3RequestSigner { + pub fn new( + access_key_id: String, + secret_access_key: String, + region: String, + session_token: Option, + ) -> Self { + Self { + access_key_id, + secret_access_key, + region, + service: "s3".to_string(), + session_token, + } + } + + /// Sign an outbound request using AWS SigV4. + /// + /// This adds Authorization, x-amz-date, x-amz-content-sha256, and Host + /// headers to the provided header map. + pub fn sign_request( + &self, + method: &http::Method, + url: &url::Url, + headers: &mut HeaderMap, + payload_hash: &str, + ) -> Result<(), ProxyError> { + use chrono::Utc; + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + let now = Utc::now(); + let date_stamp = now.format("%Y%m%d").to_string(); + let amz_date = now.format("%Y%m%dT%H%M%SZ").to_string(); + + // Set required headers + headers.insert("x-amz-date", amz_date.parse().unwrap()); + headers.insert("x-amz-content-sha256", payload_hash.parse().unwrap()); + + if let Some(token) = &self.session_token { + headers.insert("x-amz-security-token", token.parse().unwrap()); + } + + let host = url + .host_str() + .ok_or_else(|| ProxyError::Internal("no host in URL".into()))?; + let host_header = if let Some(port) = url.port() { + format!("{}:{}", host, port) + } else { + host.to_string() + }; + headers.insert("host", host_header.parse().unwrap()); + + // Canonical request + let canonical_uri = url.path(); + let canonical_querystring = url.query().unwrap_or(""); + + let mut signed_header_names: Vec<&str> = headers.keys().map(|k| k.as_str()).collect(); + signed_header_names.sort(); + + let canonical_headers: String = signed_header_names + .iter() + .map(|k| { + let v = headers.get(*k).unwrap().to_str().unwrap_or("").trim(); + format!("{}:{}\n", k, v) + }) + .collect(); + + let signed_headers = signed_header_names.join(";"); + + let canonical_request = format!( + "{}\n{}\n{}\n{}\n{}\n{}", + method, + canonical_uri, + canonical_querystring, + canonical_headers, + signed_headers, + payload_hash + ); + + // String to sign + let credential_scope = format!( + "{}/{}/{}/aws4_request", + date_stamp, self.region, self.service + ); + + use sha2::Digest; + let canonical_request_hash = hex::encode(Sha256::digest(canonical_request.as_bytes())); + + let string_to_sign = format!( + "AWS4-HMAC-SHA256\n{}\n{}\n{}", + amz_date, credential_scope, canonical_request_hash + ); + + // Signing key + type HmacSha256 = Hmac; + + let mut mac = + HmacSha256::new_from_slice(format!("AWS4{}", self.secret_access_key).as_bytes()) + .map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(date_stamp.as_bytes()); + let k_date = mac.finalize().into_bytes(); + + let mut mac = + HmacSha256::new_from_slice(&k_date).map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(self.region.as_bytes()); + let k_region = mac.finalize().into_bytes(); + + let mut mac = HmacSha256::new_from_slice(&k_region) + .map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(self.service.as_bytes()); + let k_service = mac.finalize().into_bytes(); + + let mut mac = HmacSha256::new_from_slice(&k_service) + .map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(b"aws4_request"); + let signing_key = mac.finalize().into_bytes(); + + // Signature + let mut mac = HmacSha256::new_from_slice(&signing_key) + .map_err(|e| ProxyError::Internal(e.to_string()))?; + mac.update(string_to_sign.as_bytes()); + let signature = hex::encode(mac.finalize().into_bytes()); + + // Authorization header + let auth_header = format!( + "AWS4-HMAC-SHA256 Credential={}/{}, SignedHeaders={}, Signature={}", + self.access_key_id, credential_scope, signed_headers, signature + ); + headers.insert("authorization", auth_header.parse().unwrap()); + + Ok(()) + } +} + +/// Signer for anonymous/credential-less backends. +/// +/// Returns unsigned URLs — no auth query params, no time calls. This avoids +/// the `InstanceCredentialProvider` → `TokenCache` → `Instant::now()` path +/// in `object_store` which panics on `wasm32-unknown-unknown`. +#[derive(Debug)] +struct UnsignedUrlSigner { + endpoint: String, + bucket: String, +} + +impl UnsignedUrlSigner { + fn from_config(config: &BucketConfig) -> Result { + let endpoint = config + .option("endpoint") + .unwrap_or("https://s3.amazonaws.com"); + let bucket = config.option("bucket_name").unwrap_or(""); + Ok(Self { + endpoint: endpoint.trim_end_matches('/').to_string(), + bucket: bucket.to_string(), + }) + } +} + +#[async_trait::async_trait] +impl Signer for UnsignedUrlSigner { + async fn signed_url( + &self, + _method: http::Method, + path: &object_store::path::Path, + _expires_in: std::time::Duration, + ) -> object_store::Result { + let key = path.as_ref(); + let url_str = if self.bucket.is_empty() { + if key.is_empty() { + format!("{}/", self.endpoint) + } else { + format!("{}/{}", self.endpoint, key) + } + } else if key.is_empty() { + format!("{}/{}", self.endpoint, self.bucket) + } else { + format!("{}/{}/{}", self.endpoint, self.bucket, key) + }; + url::Url::parse(&url_str).map_err(|e| object_store::Error::Generic { + store: "UnsignedUrlSigner", + source: Box::new(e), + }) + } +} + +/// Hash a payload for SigV4. For streaming/unsigned payloads, use the +/// special sentinel value. +pub fn hash_payload(payload: &[u8]) -> String { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(payload)) +} + +/// The SigV4 sentinel for unsigned payloads (used with streaming uploads). +pub const UNSIGNED_PAYLOAD: &str = "UNSIGNED-PAYLOAD"; + +/// The SigV4 sentinel for streaming payloads. +pub const STREAMING_PAYLOAD: &str = "STREAMING-AWS4-HMAC-SHA256-PAYLOAD"; diff --git a/crates/libs/core/src/config/cached.rs b/crates/libs/core/src/config/cached.rs new file mode 100644 index 0000000..dcd6d5b --- /dev/null +++ b/crates/libs/core/src/config/cached.rs @@ -0,0 +1,202 @@ +//! Caching wrapper for any [`ConfigProvider`]. +//! +//! Adds in-memory TTL-based caching over a delegate provider. This is +//! recommended for network-backed providers (HTTP, DynamoDB, Postgres) +//! to reduce latency and load on the config backend. +//! +//! # Example +//! +//! ```rust,ignore +//! use source_coop_core::config::cached::CachedProvider; +//! use std::time::Duration; +//! +//! // Wrap any provider with a 5-minute cache +//! let provider = CachedProvider::new(my_http_provider, Duration::from_secs(300)); +//! +//! // First call hits the backend +//! let bucket = provider.get_bucket("my-bucket").await?; +//! +//! // Subsequent calls within 5 minutes return the cached value +//! let bucket_again = provider.get_bucket("my-bucket").await?; +//! ``` + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +/// A cache entry with a value and expiration time. +#[derive(Clone)] +struct CacheEntry { + value: T, + inserted_at: Instant, +} + +impl CacheEntry { + fn is_expired(&self, ttl: Duration) -> bool { + self.inserted_at.elapsed() > ttl + } +} + +/// Wraps a [`ConfigProvider`] with in-memory TTL-based caching. +/// +/// Thread-safe via `RwLock`. Cache entries are evicted lazily on access. +#[derive(Clone)] +pub struct CachedProvider

{ + inner: P, + cache: Arc, + ttl: Duration, +} + +struct CacheState { + buckets_list: RwLock>>>, + buckets: RwLock>>>, + roles: RwLock>>>, + credentials: RwLock>>>, +} + +impl CachedProvider

{ + /// Create a new caching wrapper with the given TTL. + pub fn new(inner: P, ttl: Duration) -> Self { + Self { + inner, + cache: Arc::new(CacheState { + buckets_list: RwLock::new(None), + buckets: RwLock::new(HashMap::new()), + roles: RwLock::new(HashMap::new()), + credentials: RwLock::new(HashMap::new()), + }), + ttl, + } + } + + /// Invalidate all cached entries. + pub fn invalidate_all(&self) { + if let Ok(mut lock) = self.cache.buckets_list.write() { + *lock = None; + } + if let Ok(mut lock) = self.cache.buckets.write() { + lock.clear(); + } + if let Ok(mut lock) = self.cache.roles.write() { + lock.clear(); + } + if let Ok(mut lock) = self.cache.credentials.write() { + lock.clear(); + } + } + + /// Invalidate a specific bucket entry. + pub fn invalidate_bucket(&self, name: &str) { + if let Ok(mut lock) = self.cache.buckets.write() { + lock.remove(name); + } + // Also invalidate the list since it may contain stale data + if let Ok(mut lock) = self.cache.buckets_list.write() { + *lock = None; + } + } +} + +impl ConfigProvider for CachedProvider

{ + async fn list_buckets(&self) -> Result, ProxyError> { + // Check cache + if let Ok(lock) = self.cache.buckets_list.read() { + if let Some(entry) = &*lock { + if !entry.is_expired(self.ttl) { + return Ok(entry.value.clone()); + } + } + } + + // Cache miss — fetch from inner + let result = self.inner.list_buckets().await?; + + if let Ok(mut lock) = self.cache.buckets_list.write() { + *lock = Some(CacheEntry { + value: result.clone(), + inserted_at: Instant::now(), + }); + } + + Ok(result) + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + // Check cache + if let Ok(lock) = self.cache.buckets.read() { + if let Some(entry) = lock.get(name) { + if !entry.is_expired(self.ttl) { + return Ok(entry.value.clone()); + } + } + } + + let result = self.inner.get_bucket(name).await?; + + if let Ok(mut lock) = self.cache.buckets.write() { + lock.insert( + name.to_string(), + CacheEntry { + value: result.clone(), + inserted_at: Instant::now(), + }, + ); + } + + Ok(result) + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + if let Ok(lock) = self.cache.roles.read() { + if let Some(entry) = lock.get(role_id) { + if !entry.is_expired(self.ttl) { + return Ok(entry.value.clone()); + } + } + } + + let result = self.inner.get_role(role_id).await?; + + if let Ok(mut lock) = self.cache.roles.write() { + lock.insert( + role_id.to_string(), + CacheEntry { + value: result.clone(), + inserted_at: Instant::now(), + }, + ); + } + + Ok(result) + } + + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + if let Ok(lock) = self.cache.credentials.read() { + if let Some(entry) = lock.get(access_key_id) { + if !entry.is_expired(self.ttl) { + return Ok(entry.value.clone()); + } + } + } + + let result = self.inner.get_credential(access_key_id).await?; + + if let Ok(mut lock) = self.cache.credentials.write() { + lock.insert( + access_key_id.to_string(), + CacheEntry { + value: result.clone(), + inserted_at: Instant::now(), + }, + ); + } + + Ok(result) + } +} diff --git a/crates/libs/core/src/config/dynamodb.rs b/crates/libs/core/src/config/dynamodb.rs new file mode 100644 index 0000000..d5723bb --- /dev/null +++ b/crates/libs/core/src/config/dynamodb.rs @@ -0,0 +1,169 @@ +//! DynamoDB-backed configuration provider. +//! +//! Stores configuration in DynamoDB tables. Designed for AWS-native +//! deployments where DynamoDB is readily available. +//! +//! # Table Schema +//! +//! Uses a single-table design with the following layout: +//! +//! | PK | SK | Attributes | +//! |----|----|------------| +//! | `BUCKET#{name}` | `CONFIG` | BucketConfig fields | +//! | `ROLE#{role_id}` | `CONFIG` | RoleConfig fields | +//! | `CRED#{access_key_id}` | `LONG_LIVED` | StoredCredential fields | +//! +//! # Example +//! +//! ```rust,ignore +//! use source_coop_core::config::dynamodb::DynamoDbProvider; +//! use aws_sdk_dynamodb::Client; +//! +//! let sdk_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; +//! let client = Client::new(&sdk_config); +//! let provider = DynamoDbProvider::new(client, "s3-proxy-config".to_string()); +//! ``` + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use aws_sdk_dynamodb::types::AttributeValue; +use aws_sdk_dynamodb::Client; +use std::sync::Arc; + +/// Configuration provider backed by a single DynamoDB table. +#[derive(Clone)] +pub struct DynamoDbProvider { + inner: Arc, +} + +struct DynamoDbProviderInner { + client: Client, + table_name: String, +} + +impl DynamoDbProvider { + pub fn new(client: Client, table_name: String) -> Self { + Self { + inner: Arc::new(DynamoDbProviderInner { client, table_name }), + } + } + + fn table(&self) -> &str { + &self.inner.table_name + } + + fn client(&self) -> &Client { + &self.inner.client + } +} + +impl ConfigProvider for DynamoDbProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + let result = self + .client() + .query() + .table_name(self.table()) + .key_condition_expression("begins_with(PK, :prefix)") + .expression_attribute_values(":prefix", AttributeValue::S("BUCKET#".into())) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + let items = result.items(); + let mut buckets = Vec::with_capacity(items.len()); + + for item in items { + if let Some(json_val) = item.get("config_json") { + if let Ok(s) = json_val.as_s() { + if let Ok(config) = serde_json::from_str::(s) { + buckets.push(config); + } + } + } + } + + Ok(buckets) + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + let result = self + .client() + .get_item() + .table_name(self.table()) + .key("PK", AttributeValue::S(format!("BUCKET#{}", name))) + .key("SK", AttributeValue::S("CONFIG".into())) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + match result.item() { + Some(item) => { + let json_val = item + .get("config_json") + .and_then(|v| v.as_s().ok()) + .ok_or_else(|| ProxyError::ConfigError("missing config_json".into()))?; + + let config = serde_json::from_str(json_val) + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Some(config)) + } + None => Ok(None), + } + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + let result = self + .client() + .get_item() + .table_name(self.table()) + .key("PK", AttributeValue::S(format!("ROLE#{}", role_id))) + .key("SK", AttributeValue::S("CONFIG".into())) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + match result.item() { + Some(item) => { + let json_val = item + .get("config_json") + .and_then(|v| v.as_s().ok()) + .ok_or_else(|| ProxyError::ConfigError("missing config_json".into()))?; + + let config = serde_json::from_str(json_val) + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Some(config)) + } + None => Ok(None), + } + } + + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + let result = self + .client() + .get_item() + .table_name(self.table()) + .key("PK", AttributeValue::S(format!("CRED#{}", access_key_id))) + .key("SK", AttributeValue::S("LONG_LIVED".into())) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + match result.item() { + Some(item) => { + let json_val = item + .get("config_json") + .and_then(|v| v.as_s().ok()) + .ok_or_else(|| ProxyError::ConfigError("missing config_json".into()))?; + + let config = serde_json::from_str(json_val) + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Some(config)) + } + None => Ok(None), + } + } +} diff --git a/crates/libs/core/src/config/http.rs b/crates/libs/core/src/config/http.rs new file mode 100644 index 0000000..4faa84c --- /dev/null +++ b/crates/libs/core/src/config/http.rs @@ -0,0 +1,182 @@ +//! HTTP API-backed configuration provider. +//! +//! Fetches configuration from a centralized REST API. Useful when you have +//! a control plane service that manages proxy configuration. +//! +//! # Expected API Contract +//! +//! The API should expose: +//! - `GET /buckets` → `Vec` +//! - `GET /buckets/{name}` → `Option` +//! - `GET /roles/{role_id}` → `Option` +//! - `GET /credentials/{access_key_id}` → `Option` +//! +//! # Example +//! +//! ```rust,ignore +//! use source_coop_core::config::http::HttpProvider; +//! +//! let provider = HttpProvider::new( +//! "https://config-api.internal:8080".to_string(), +//! Some("Bearer my-api-token".to_string()), +//! ); +//! ``` + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use std::sync::Arc; + +/// Validate that a value is safe to use as a single URL path segment. +/// +/// Rejects values containing `/`, `\`, `..`, null bytes, or that are empty, +/// to prevent path traversal against the config API. +fn validate_path_segment(value: &str, param_name: &str) -> Result<(), ProxyError> { + if value.is_empty() + || value.contains('/') + || value.contains('\\') + || value.contains('\0') + || value == ".." + || value == "." + { + return Err(ProxyError::InvalidRequest(format!( + "invalid {}: contains illegal characters", + param_name + ))); + } + Ok(()) +} + +/// Configuration provider that reads from a REST API. +#[derive(Clone)] +pub struct HttpProvider { + inner: Arc, +} + +struct HttpProviderInner { + base_url: String, + client: reqwest::Client, + auth_header: Option, +} + +impl HttpProvider { + /// Create a new HTTP config provider. + /// + /// `base_url`: The base URL of the config API (no trailing slash). + /// `auth_header`: Optional Authorization header value (e.g., "Bearer ..."). + pub fn new(base_url: String, auth_header: Option) -> Self { + Self { + inner: Arc::new(HttpProviderInner { + base_url: base_url.trim_end_matches('/').to_string(), + client: reqwest::Client::new(), + auth_header, + }), + } + } + + fn request(&self, path: &str) -> reqwest::RequestBuilder { + let mut req = self + .inner + .client + .get(format!("{}{}", self.inner.base_url, path)); + if let Some(ref auth) = self.inner.auth_header { + req = req.header("authorization", auth); + } + req + } +} + +impl ConfigProvider for HttpProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + let resp = self + .request("/buckets") + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + resp.json() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string())) + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + validate_path_segment(name, "bucket name")?; + let resp = self + .request(&format!("/buckets/{}", name)) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + resp.json() + .await + .map(Some) + .map_err(|e| ProxyError::ConfigError(e.to_string())) + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + validate_path_segment(role_id, "role ID")?; + let resp = self + .request(&format!("/roles/{}", role_id)) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + resp.json() + .await + .map(Some) + .map_err(|e| ProxyError::ConfigError(e.to_string())) + } + + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + validate_path_segment(access_key_id, "access key ID")?; + let resp = self + .request(&format!("/credentials/{}", access_key_id)) + .send() + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + if resp.status().as_u16() == 404 { + return Ok(None); + } + + resp.json() + .await + .map(Some) + .map_err(|e| ProxyError::ConfigError(e.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_path_segment_rejects_traversal() { + assert!(validate_path_segment("../admin", "test").is_err()); + assert!(validate_path_segment("foo/bar", "test").is_err()); + assert!(validate_path_segment("foo\\bar", "test").is_err()); + assert!(validate_path_segment("..", "test").is_err()); + assert!(validate_path_segment(".", "test").is_err()); + assert!(validate_path_segment("", "test").is_err()); + assert!(validate_path_segment("foo\0bar", "test").is_err()); + } + + #[test] + fn validate_path_segment_accepts_normal_values() { + assert!(validate_path_segment("my-bucket", "test").is_ok()); + assert!(validate_path_segment("AKIAIOSFODNN7EXAMPLE", "test").is_ok()); + assert!(validate_path_segment("role-123_abc", "test").is_ok()); + assert!(validate_path_segment("bucket.with.dots", "test").is_ok()); + } +} diff --git a/crates/libs/core/src/config/mod.rs b/crates/libs/core/src/config/mod.rs new file mode 100644 index 0000000..d766217 --- /dev/null +++ b/crates/libs/core/src/config/mod.rs @@ -0,0 +1,78 @@ +//! Configuration provider abstraction and implementations. +//! +//! The [`ConfigProvider`] trait defines how the proxy retrieves its +//! configuration (buckets, roles, credentials) from a backend store. +//! This allows the same core logic to work with static files, databases, +//! HTTP APIs, or any other configuration source. +//! +//! # Available Implementations +//! +//! | Provider | Feature Flag | Use Case | +//! |----------|-------------|----------| +//! | [`StaticProvider`](static_file::StaticProvider) | *(always available)* | TOML/JSON config files, baked-in config | +//! | [`HttpProvider`](http::HttpProvider) | `config-http` | Centralized config API | +//! | [`DynamoDbProvider`](dynamodb::DynamoDbProvider) | `config-dynamodb` | AWS-native deployments | +//! | [`PostgresProvider`](postgres::PostgresProvider) | `config-postgres` | Database-backed config | +//! +//! # Caching +//! +//! Wrap any provider with [`CachedProvider`](cached::CachedProvider) to add +//! in-memory TTL-based caching. This is recommended for providers that make +//! network calls (HTTP, DynamoDB, Postgres). +//! +//! ```rust,ignore +//! use source_coop_core::config::{cached::CachedProvider, static_file::StaticProvider}; +//! use std::time::Duration; +//! +//! let base = StaticProvider::from_file("config.toml").unwrap(); +//! let cached = CachedProvider::new(base, Duration::from_secs(300)); +//! ``` + +pub mod cached; +pub mod static_file; + +#[cfg(feature = "config-http")] +pub mod http; + +#[cfg(feature = "config-dynamodb")] +pub mod dynamodb; + +#[cfg(feature = "config-postgres")] +pub mod postgres; + +use crate::error::ProxyError; +use crate::maybe_send::{MaybeSend, MaybeSync}; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use std::future::Future; + +/// Trait for retrieving proxy configuration from a backend store. +/// +/// Implementations should be cheap to clone (wrap inner state in `Arc`). +/// +/// Methods use [`MaybeSend`] bounds — on native targets this resolves to `Send` +/// (required by Tokio's task spawning), on WASM it's a no-op (allowing `!Send` +/// JS interop types). +/// +/// Temporary credentials are not stored via this trait — they are encrypted +/// into self-contained session tokens using [`TokenKey`](crate::sealed_token::TokenKey). +pub trait ConfigProvider: Clone + MaybeSend + MaybeSync + 'static { + fn list_buckets( + &self, + ) -> impl Future, ProxyError>> + MaybeSend; + + fn get_bucket( + &self, + name: &str, + ) -> impl Future, ProxyError>> + MaybeSend; + + fn get_role( + &self, + role_id: &str, + ) -> impl Future, ProxyError>> + MaybeSend; + + /// Look up a long-lived credential by its access key ID. + fn get_credential( + &self, + access_key_id: &str, + ) -> impl Future, ProxyError>> + MaybeSend; +} diff --git a/crates/libs/core/src/config/postgres.rs b/crates/libs/core/src/config/postgres.rs new file mode 100644 index 0000000..2b4f673 --- /dev/null +++ b/crates/libs/core/src/config/postgres.rs @@ -0,0 +1,116 @@ +//! PostgreSQL-backed configuration provider. +//! +//! Stores configuration in a Postgres database. Good for deployments where +//! you already have a Postgres instance and want transactional config updates. +//! +//! # Required Tables +//! +//! ```sql +//! CREATE TABLE proxy_buckets ( +//! name TEXT PRIMARY KEY, +//! config_json JSONB NOT NULL +//! ); +//! +//! CREATE TABLE proxy_roles ( +//! role_id TEXT PRIMARY KEY, +//! config_json JSONB NOT NULL +//! ); +//! +//! CREATE TABLE proxy_credentials ( +//! access_key_id TEXT PRIMARY KEY, +//! config_json JSONB NOT NULL +//! ); +//! ``` +//! +//! # Example +//! +//! ```rust,ignore +//! use source_coop_core::config::postgres::PostgresProvider; +//! use sqlx::PgPool; +//! +//! let pool = PgPool::connect("postgres://user:pass@localhost/s3proxy").await?; +//! let provider = PostgresProvider::new(pool); +//! ``` + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use sqlx::PgPool; +use std::sync::Arc; + +/// Configuration provider backed by PostgreSQL. +#[derive(Clone)] +pub struct PostgresProvider { + pool: Arc, +} + +impl PostgresProvider { + pub fn new(pool: PgPool) -> Self { + Self { + pool: Arc::new(pool), + } + } +} + +impl ConfigProvider for PostgresProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + let rows: Vec<(serde_json::Value,)> = + sqlx::query_as("SELECT config_json FROM proxy_buckets") + .fetch_all(self.pool.as_ref()) + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + rows.into_iter() + .map(|(json,)| { + serde_json::from_value(json).map_err(|e| ProxyError::ConfigError(e.to_string())) + }) + .collect() + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + let row: Option<(serde_json::Value,)> = + sqlx::query_as("SELECT config_json FROM proxy_buckets WHERE name = $1") + .bind(name) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + row.map(|(json,)| { + serde_json::from_value(json).map_err(|e| ProxyError::ConfigError(e.to_string())) + }) + .transpose() + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + let row: Option<(serde_json::Value,)> = + sqlx::query_as("SELECT config_json FROM proxy_roles WHERE role_id = $1") + .bind(role_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + row.map(|(json,)| { + serde_json::from_value(json).map_err(|e| ProxyError::ConfigError(e.to_string())) + }) + .transpose() + } + + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + let row: Option<(serde_json::Value,)> = sqlx::query_as( + "SELECT config_json FROM proxy_credentials + WHERE access_key_id = $1 AND credential_type = 'long_lived'", + ) + .bind(access_key_id) + .fetch_optional(self.pool.as_ref()) + .await + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + + row.map(|(json,)| { + serde_json::from_value(json).map_err(|e| ProxyError::ConfigError(e.to_string())) + }) + .transpose() + } +} diff --git a/crates/libs/core/src/config/static_file.rs b/crates/libs/core/src/config/static_file.rs new file mode 100644 index 0000000..623e420 --- /dev/null +++ b/crates/libs/core/src/config/static_file.rs @@ -0,0 +1,122 @@ +//! Static file-based configuration provider. +//! +//! Loads configuration from a TOML or JSON file at startup. +//! Suitable for simple deployments or development. + +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::types::{BucketConfig, RoleConfig, StoredCredential}; +use serde::Deserialize; +use std::sync::Arc; + +/// Full configuration file structure. +#[derive(Debug, Clone, Deserialize)] +pub struct StaticConfig { + #[serde(default)] + pub buckets: Vec, + #[serde(default)] + pub roles: Vec, + #[serde(default)] + pub credentials: Vec, +} + +/// Configuration provider backed by a static TOML/JSON file. +/// +/// # Example +/// +/// ```rust,ignore +/// let provider = StaticProvider::from_toml(r#" +/// [[buckets]] +/// name = "public-data" +/// backend_type = "s3" +/// anonymous_access = true +/// allowed_roles = [] +/// +/// [buckets.backend_options] +/// endpoint = "https://s3.amazonaws.com" +/// bucket_name = "my-real-bucket" +/// region = "us-east-1" +/// access_key_id = "AKIA..." +/// secret_access_key = "..." +/// "#)?; +/// ``` +#[derive(Clone)] +pub struct StaticProvider { + inner: Arc, +} + +struct StaticProviderInner { + config: StaticConfig, +} + +impl StaticProvider { + /// Parse a TOML string into a provider. + pub fn from_toml(toml_str: &str) -> Result { + let config: StaticConfig = + toml::from_str(toml_str).map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Self::from_config(config)) + } + + /// Parse a JSON string into a provider. + pub fn from_json(json_str: &str) -> Result { + let config: StaticConfig = + serde_json::from_str(json_str).map_err(|e| ProxyError::ConfigError(e.to_string()))?; + Ok(Self::from_config(config)) + } + + /// Read and parse a TOML file. + pub fn from_file(path: &str) -> Result { + let content = + std::fs::read_to_string(path).map_err(|e| ProxyError::ConfigError(e.to_string()))?; + if path.ends_with(".json") { + Self::from_json(&content) + } else { + Self::from_toml(&content) + } + } + + pub fn from_config(config: StaticConfig) -> Self { + Self { + inner: Arc::new(StaticProviderInner { config }), + } + } +} + +impl ConfigProvider for StaticProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + Ok(self.inner.config.buckets.clone()) + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + Ok(self + .inner + .config + .buckets + .iter() + .find(|b| b.name == name) + .cloned()) + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + Ok(self + .inner + .config + .roles + .iter() + .find(|r| r.role_id == role_id) + .cloned()) + } + + async fn get_credential( + &self, + access_key_id: &str, + ) -> Result, ProxyError> { + Ok(self + .inner + .config + .credentials + .iter() + .find(|c| c.access_key_id == access_key_id) + .cloned()) + } +} diff --git a/crates/libs/core/src/error.rs b/crates/libs/core/src/error.rs new file mode 100644 index 0000000..816c13c --- /dev/null +++ b/crates/libs/core/src/error.rs @@ -0,0 +1,110 @@ +//! Error types for the proxy. + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ProxyError { + #[error("bucket not found: {0}")] + BucketNotFound(String), + + #[error("no such key: {0}")] + NoSuchKey(String), + + #[error("access denied")] + AccessDenied, + + #[error("signature mismatch")] + SignatureDoesNotMatch, + + #[error("invalid request: {0}")] + InvalidRequest(String), + + #[error("missing authentication")] + MissingAuth, + + #[error("expired credentials")] + ExpiredCredentials, + + #[error("invalid OIDC token: {0}")] + InvalidOidcToken(String), + + #[error("role not found: {0}")] + RoleNotFound(String), + + #[error("backend error: {0}")] + BackendError(String), + + #[error("precondition failed")] + PreconditionFailed, + + #[error("not modified")] + NotModified, + + #[error("config error: {0}")] + ConfigError(String), + + #[error("internal error: {0}")] + Internal(String), +} + +impl ProxyError { + /// Return the S3-compatible XML error code. + pub fn s3_error_code(&self) -> &'static str { + match self { + Self::BucketNotFound(_) => "NoSuchBucket", + Self::NoSuchKey(_) => "NoSuchKey", + Self::AccessDenied => "AccessDenied", + Self::SignatureDoesNotMatch => "SignatureDoesNotMatch", + Self::InvalidRequest(_) => "InvalidRequest", + Self::MissingAuth => "AccessDenied", + Self::ExpiredCredentials => "ExpiredToken", + Self::InvalidOidcToken(_) => "InvalidIdentityToken", + Self::RoleNotFound(_) => "AccessDenied", + Self::BackendError(_) => "ServiceUnavailable", + Self::PreconditionFailed => "PreconditionFailed", + Self::NotModified => "NotModified", + Self::ConfigError(_) => "InternalError", + Self::Internal(_) => "InternalError", + } + } + + /// HTTP status code for this error. + pub fn status_code(&self) -> u16 { + match self { + Self::BucketNotFound(_) | Self::NoSuchKey(_) => 404, + Self::AccessDenied | Self::MissingAuth | Self::ExpiredCredentials => 403, + Self::SignatureDoesNotMatch => 403, + Self::InvalidRequest(_) => 400, + Self::InvalidOidcToken(_) => 400, + Self::RoleNotFound(_) => 403, + Self::PreconditionFailed => 412, + Self::NotModified => 304, + Self::BackendError(_) => 503, + Self::ConfigError(_) | Self::Internal(_) => 500, + } + } + + /// Return a message safe to show to external clients. + /// + /// For server-side errors (5xx), returns a generic message to avoid + /// leaking backend infrastructure details. For client errors (4xx), + /// returns the full message (the client already knows the bucket name, + /// key, etc.). + pub fn safe_message(&self) -> String { + match self { + Self::BackendError(_) => "Service unavailable".to_string(), + Self::ConfigError(_) | Self::Internal(_) => "Internal server error".to_string(), + other => other.to_string(), + } + } + + /// Convert an `object_store::Error` into a `ProxyError`. + pub fn from_object_store_error(e: object_store::Error) -> Self { + match e { + object_store::Error::NotFound { path, .. } => Self::NoSuchKey(path), + object_store::Error::Precondition { .. } => Self::PreconditionFailed, + object_store::Error::NotModified { .. } => Self::NotModified, + _ => Self::BackendError(e.to_string()), + } + } +} diff --git a/crates/libs/core/src/lib.rs b/crates/libs/core/src/lib.rs new file mode 100644 index 0000000..0daf967 --- /dev/null +++ b/crates/libs/core/src/lib.rs @@ -0,0 +1,32 @@ +//! # s3-proxy-core +//! +//! Runtime-agnostic core library for the S3 proxy gateway. +//! +//! This crate defines the trait abstractions that allow the proxy to run on +//! multiple runtimes (Tokio/Hyper for containers, Cloudflare Workers for edge) +//! without either runtime leaking into the core logic. +//! +//! ## Key Abstractions +//! +//! - [`response_body::ProxyResponseBody`] — concrete response body type (Stream, Bytes, Empty) +//! - [`backend::ProxyBackend`] — create object stores and send raw HTTP requests +//! - [`config::ConfigProvider`] — retrieve bucket/role/credential configuration from any backend +//! - [`auth`] — SigV4 request verification and credential resolution +//! - [`s3::request`] — parse incoming S3 API requests into typed operations +//! - [`s3::response`] — serialize S3 XML responses +//! - [`proxy::ProxyHandler`] — the main request handler that ties everything together + +pub mod auth; +#[cfg(feature = "axum")] +pub mod axum; +pub mod backend; +pub mod config; +pub mod error; +pub mod maybe_send; +pub mod oidc_backend; +pub mod proxy; +pub mod resolver; +pub mod response_body; +pub mod s3; +pub mod sealed_token; +pub mod types; diff --git a/crates/libs/core/src/maybe_send.rs b/crates/libs/core/src/maybe_send.rs new file mode 100644 index 0000000..fd3b71a --- /dev/null +++ b/crates/libs/core/src/maybe_send.rs @@ -0,0 +1,45 @@ +//! Conditional `Send`/`Sync` bounds for multi-runtime compatibility. +//! +//! On native targets (x86_64, aarch64, etc.), `MaybeSend` resolves to `Send` +//! and `MaybeSync` resolves to `Sync`. This satisfies Tokio's requirement +//! that spawned futures are `Send`. +//! +//! On `wasm32` targets, these traits are no-ops — blanket-implemented for all +//! types. This allows Cloudflare Workers code to use `!Send` JS interop types +//! (`Rc>`, `JsValue`, etc.) without constraint violations. +//! +//! ## Usage +//! +//! Use `MaybeSend` instead of `Send` in trait bounds throughout the core: +//! +//! ```rust,ignore +//! use source_coop_core::maybe_send::MaybeSend; +//! +//! pub trait MyTrait: MaybeSend { +//! fn do_work(&self) -> impl Future + MaybeSend; +//! } +//! ``` + +// --- Native targets: MaybeSend = Send, MaybeSync = Sync --- + +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSend: Send {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSend for T {} + +#[cfg(not(target_arch = "wasm32"))] +pub trait MaybeSync: Sync {} +#[cfg(not(target_arch = "wasm32"))] +impl MaybeSync for T {} + +// --- WASM targets: MaybeSend and MaybeSync are no-ops --- + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSend {} +#[cfg(target_arch = "wasm32")] +impl MaybeSend for T {} + +#[cfg(target_arch = "wasm32")] +pub trait MaybeSync {} +#[cfg(target_arch = "wasm32")] +impl MaybeSync for T {} diff --git a/crates/libs/core/src/oidc_backend.rs b/crates/libs/core/src/oidc_backend.rs new file mode 100644 index 0000000..031c4d2 --- /dev/null +++ b/crates/libs/core/src/oidc_backend.rs @@ -0,0 +1,44 @@ +//! Trait for OIDC-based backend credential resolution. +//! +//! When a bucket is configured with `auth_type=oidc`, the proxy mints a +//! self-signed JWT and exchanges it with the cloud provider's STS for +//! temporary credentials. The resolved credentials are injected into the +//! `BucketConfig.backend_options` so the existing builder pipeline works +//! unmodified. +//! +//! [`NoOidcAuth`] is the default no-op implementation used when no OIDC +//! provider is configured. + +use crate::error::ProxyError; +use crate::maybe_send::MaybeSend; +use crate::types::BucketConfig; +use std::future::Future; + +/// Resolves backend credentials via OIDC token exchange. +/// +/// Called at the top of `dispatch_operation()` before the config reaches +/// `create_store()` / `create_signer()`. Implementations may return the +/// config unchanged (no `auth_type=oidc`) or inject temporary credentials. +pub trait OidcBackendAuth: MaybeSend + 'static { + fn resolve_credentials( + &self, + config: &BucketConfig, + ) -> impl Future> + MaybeSend; +} + +/// No-op implementation — returns config unchanged. +/// +/// If a bucket specifies `auth_type=oidc` but no OIDC provider is +/// configured, this returns a `ConfigError`. +pub struct NoOidcAuth; + +impl OidcBackendAuth for NoOidcAuth { + async fn resolve_credentials(&self, config: &BucketConfig) -> Result { + if config.option("auth_type") == Some("oidc") { + return Err(ProxyError::ConfigError( + "bucket requires auth_type=oidc but no OIDC provider is configured".into(), + )); + } + Ok(config.clone()) + } +} diff --git a/crates/libs/core/src/proxy.rs b/crates/libs/core/src/proxy.rs new file mode 100644 index 0000000..0abbb75 --- /dev/null +++ b/crates/libs/core/src/proxy.rs @@ -0,0 +1,947 @@ +//! The main proxy handler that ties together resolution and backend forwarding. +//! +//! [`ProxyHandler`] is generic over the runtime's backend and request resolver. +//! It uses a two-phase dispatch model: +//! +//! 1. **`resolve_request`** — parses, authenticates, and decides the action: +//! - GET/HEAD/PUT/DELETE → [`HandlerAction::Forward`] with a presigned URL +//! - LIST → [`HandlerAction::Response`] with XML body +//! - Multipart → [`HandlerAction::NeedsBody`] (body required) +//! - Errors/synthetic → [`HandlerAction::Response`] +//! +//! 2. **`handle_with_body`** — completes multipart operations once the body arrives. +//! +//! Runtimes handle [`Forward`] by executing the presigned URL with their native +//! HTTP client, enabling zero-copy streaming for both request and response bodies. + +use crate::backend::{hash_payload, ProxyBackend, S3RequestSigner, UNSIGNED_PAYLOAD}; +use crate::error::ProxyError; +use crate::oidc_backend::{NoOidcAuth, OidcBackendAuth}; +use crate::resolver::{ListRewrite, RequestResolver, ResolvedAction}; +use crate::response_body::ProxyResponseBody; +use crate::s3::response::{ErrorResponse, ListBucketResult, ListCommonPrefix, ListContents}; +use crate::types::{BucketConfig, S3Operation}; +use bytes::Bytes; +use http::{HeaderMap, Method}; +use object_store::list::PaginatedListOptions; +use std::borrow::Cow; +use std::time::Duration; +use url::Url; +use uuid::Uuid; + +/// TTL for presigned URLs. Short because they're used immediately. +const PRESIGNED_URL_TTL: Duration = Duration::from_secs(300); + +/// The action the handler wants the runtime to take. +pub enum HandlerAction { + /// A fully formed response (LIST results, errors, synthetic responses). + Response(ProxyResult), + /// A presigned URL for the runtime to execute with its native HTTP client. + /// The runtime streams request/response bodies directly — no handler involvement. + Forward(ForwardRequest), + /// The handler needs the request body to continue (multipart operations). + /// The runtime should materialize the body and call `handle_with_body`. + NeedsBody(PendingRequest), +} + +/// A presigned URL request for the runtime to execute. +pub struct ForwardRequest { + /// HTTP method for the backend request. + pub method: Method, + /// Presigned URL to the backend (includes auth in query params). + pub url: Url, + /// Headers to include in the backend request (Range, If-Match, Content-Type, etc.). + pub headers: HeaderMap, +} + +/// Opaque state for a multipart operation that needs the request body. +pub struct PendingRequest { + method: Method, + operation: S3Operation, + bucket_config: BucketConfig, + original_headers: HeaderMap, + request_id: String, +} + +/// The core proxy handler, generic over runtime primitives. +/// +/// # Type Parameters +/// +/// - `B`: The runtime's backend for object store creation, signing, and raw HTTP +/// - `R`: The request resolver that decides what action to take for each request +/// - `O`: OIDC backend auth for resolving credentials via token exchange +pub struct ProxyHandler { + backend: B, + resolver: R, + oidc_auth: O, + /// When true, error responses include full internal details (for development). + /// When false, server-side errors use generic messages. + debug_errors: bool, +} + +impl ProxyHandler +where + B: ProxyBackend, + R: RequestResolver, +{ + pub fn new(backend: B, resolver: R) -> Self { + Self { + backend, + resolver, + oidc_auth: NoOidcAuth, + debug_errors: false, + } + } +} + +impl ProxyHandler +where + B: ProxyBackend, + R: RequestResolver, + O: OidcBackendAuth, +{ + /// Set the OIDC backend auth implementation. + /// + /// When configured, `dispatch_operation` calls `resolve_credentials` + /// before accessing the backend — enabling OIDC-based credential + /// resolution for buckets with `auth_type=oidc`. + pub fn with_oidc_auth(self, oidc_auth: O2) -> ProxyHandler { + ProxyHandler { + backend: self.backend, + resolver: self.resolver, + oidc_auth, + debug_errors: self.debug_errors, + } + } + + /// Enable verbose error messages in S3 error responses. + /// + /// When enabled, 500-class errors include the full internal message + /// (backend errors, config errors, etc.). Disable in production to + /// avoid leaking infrastructure details to clients. + pub fn with_debug_errors(mut self, enabled: bool) -> Self { + self.debug_errors = enabled; + self + } + + /// Phase 1: Resolve an incoming request into an action. + /// + /// This is the main entry point. It: + /// 1. Resolves the request via the resolver (parse, auth, authorize) + /// 2. Determines what the runtime should do next: + /// - `Forward` a presigned URL (GET/HEAD/PUT/DELETE) + /// - Return a `Response` directly (LIST, errors, synthetic) + /// - Request the body via `NeedsBody` (multipart) + pub async fn resolve_request( + &self, + method: Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> HandlerAction { + let request_id = Uuid::new_v4().to_string(); + + tracing::info!( + request_id = %request_id, + method = %method, + path = %path, + query = ?query, + "incoming request" + ); + + match self + .resolve_inner(method, path, query, headers, &request_id) + .await + { + Ok(action) => { + match &action { + HandlerAction::Response(resp) => { + tracing::info!( + request_id = %request_id, + status = resp.status, + "request completed" + ); + } + HandlerAction::Forward(fwd) => { + tracing::info!( + request_id = %request_id, + method = %fwd.method, + "forwarding via presigned URL" + ); + } + HandlerAction::NeedsBody(_) => { + tracing::debug!( + request_id = %request_id, + "request needs body (multipart)" + ); + } + } + action + } + Err(err) => { + tracing::warn!( + request_id = %request_id, + error = %err, + status = err.status_code(), + s3_code = %err.s3_error_code(), + "request failed" + ); + HandlerAction::Response(error_response(&err, path, &request_id, self.debug_errors)) + } + } + } + + /// Phase 2: Complete a multipart operation with the request body. + /// + /// Called by the runtime after materializing the body for a `NeedsBody` action. + pub async fn handle_with_body(&self, pending: PendingRequest, body: Bytes) -> ProxyResult { + match self.execute_multipart(&pending, body).await { + Ok(result) => { + tracing::info!( + request_id = %pending.request_id, + status = result.status, + "multipart request completed" + ); + result + } + Err(err) => { + tracing::warn!( + request_id = %pending.request_id, + error = %err, + status = err.status_code(), + s3_code = %err.s3_error_code(), + "multipart request failed" + ); + error_response( + &err, + pending.operation.key(), + &pending.request_id, + self.debug_errors, + ) + } + } + } + + async fn resolve_inner( + &self, + method: Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + request_id: &str, + ) -> Result { + let action = self.resolver.resolve(&method, path, query, headers).await?; + + match action { + ResolvedAction::Response { + status, + headers: resp_headers, + body: resp_body, + } => Ok(HandlerAction::Response(ProxyResult { + status, + headers: resp_headers, + body: ProxyResponseBody::from_bytes(resp_body), + })), + ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite, + } => { + self.dispatch_operation( + &method, + &operation, + &bucket_config, + headers, + list_rewrite.as_ref(), + request_id, + ) + .await + } + } + } + + async fn dispatch_operation( + &self, + method: &Method, + operation: &S3Operation, + bucket_config: &BucketConfig, + original_headers: &HeaderMap, + list_rewrite: Option<&ListRewrite>, + request_id: &str, + ) -> Result { + // Resolve OIDC credentials if auth_type=oidc is configured. + // This injects temporary credentials into a cloned config so the + // existing builder pipeline works unmodified. + let bucket_config = self.oidc_auth.resolve_credentials(bucket_config).await?; + let bucket_config = &bucket_config; + + match operation { + S3Operation::GetObject { key, .. } => { + let fwd = self + .build_forward( + Method::GET, + bucket_config, + key, + original_headers, + &[ + "range", + "if-match", + "if-none-match", + "if-modified-since", + "if-unmodified-since", + ], + ) + .await?; + tracing::debug!(path = fwd.url.path(), "GET via presigned URL"); + Ok(HandlerAction::Forward(fwd)) + } + S3Operation::HeadObject { key, .. } => { + let fwd = self + .build_forward( + Method::HEAD, + bucket_config, + key, + original_headers, + &[ + "if-match", + "if-none-match", + "if-modified-since", + "if-unmodified-since", + ], + ) + .await?; + tracing::debug!(path = fwd.url.path(), "HEAD via presigned URL"); + Ok(HandlerAction::Forward(fwd)) + } + S3Operation::PutObject { key, .. } => { + let fwd = self + .build_forward( + Method::PUT, + bucket_config, + key, + original_headers, + &["content-type", "content-length", "content-md5"], + ) + .await?; + tracing::debug!(path = fwd.url.path(), "PUT via presigned URL"); + Ok(HandlerAction::Forward(fwd)) + } + S3Operation::DeleteObject { key, .. } => { + let fwd = self + .build_forward(Method::DELETE, bucket_config, key, original_headers, &[]) + .await?; + tracing::debug!(path = fwd.url.path(), "DELETE via presigned URL"); + Ok(HandlerAction::Forward(fwd)) + } + S3Operation::ListBucket { raw_query, .. } => { + let result = self + .handle_list(bucket_config, raw_query.as_deref(), list_rewrite) + .await?; + Ok(HandlerAction::Response(result)) + } + // Multipart operations need the request body + S3Operation::CreateMultipartUpload { .. } + | S3Operation::UploadPart { .. } + | S3Operation::CompleteMultipartUpload { .. } + | S3Operation::AbortMultipartUpload { .. } => { + if !bucket_config.supports_s3_multipart() { + return Err(ProxyError::InvalidRequest(format!( + "multipart operations not supported for '{}' backends", + bucket_config.backend_type + ))); + } + Ok(HandlerAction::NeedsBody(PendingRequest { + method: method.clone(), + operation: operation.clone(), + bucket_config: bucket_config.clone(), + original_headers: original_headers.clone(), + request_id: request_id.to_string(), + })) + } + _ => Err(ProxyError::Internal("unexpected operation".into())), + } + } + + /// Build a [`ForwardRequest`] with a presigned URL for the given operation. + async fn build_forward( + &self, + method: Method, + config: &BucketConfig, + key: &str, + original_headers: &HeaderMap, + forward_header_names: &[&'static str], + ) -> Result { + let signer = self.backend.create_signer(config)?; + let path = build_object_path(config, key); + + let url = signer + .signed_url(method.clone(), &path, PRESIGNED_URL_TTL) + .await + .map_err(ProxyError::from_object_store_error)?; + + let mut fwd_headers = HeaderMap::new(); + for name in forward_header_names { + if let Some(v) = original_headers.get(*name) { + fwd_headers.insert(*name, v.clone()); + } + } + + Ok(ForwardRequest { + method, + url, + headers: fwd_headers, + }) + } + + /// LIST via object_store's `PaginatedListStore`. + /// + /// Pagination is pushed to the backend — only one page of results is fetched + /// per request, avoiding loading all objects into memory. + async fn handle_list( + &self, + config: &BucketConfig, + raw_query: Option<&str>, + list_rewrite: Option<&ListRewrite>, + ) -> Result { + let store = self.backend.create_paginated_store(config)?; + + // Parse all query parameters in a single pass + let list_params = parse_list_query_params(raw_query); + let client_prefix = &list_params.prefix; + let delimiter = &list_params.delimiter; + + // Build the full prefix including backend_prefix + let full_prefix = build_list_prefix(config, client_prefix); + + // Map start-after to raw key space by prepending backend_prefix + let offset = list_params + .start_after + .as_ref() + .map(|sa| build_list_prefix(config, sa)); + + tracing::debug!( + full_prefix = %full_prefix, + delimiter = %delimiter, + max_keys = list_params.max_keys, + has_page_token = list_params.continuation_token.is_some(), + "LIST via PaginatedListStore" + ); + + let prefix = if full_prefix.is_empty() { + None + } else { + Some(full_prefix.as_str()) + }; + + let opts = PaginatedListOptions { + offset, + delimiter: Some(Cow::Owned(delimiter.clone())), + max_keys: Some(list_params.max_keys), + page_token: list_params.continuation_token.clone(), + ..Default::default() + }; + + let paginated = store + .list_paginated(prefix, opts) + .await + .map_err(ProxyError::from_object_store_error)?; + + // Build S3 XML response from paginated result + let key_count = paginated.result.objects.len() + paginated.result.common_prefixes.len(); + let xml = build_list_xml( + &ListXmlParams { + bucket_name: &config.name, + client_prefix, + delimiter, + max_keys: list_params.max_keys, + is_truncated: paginated.page_token.is_some(), + key_count, + start_after: &list_params.start_after, + continuation_token: &list_params.continuation_token, + next_continuation_token: paginated.page_token, + }, + &paginated.result, + config, + list_rewrite, + )?; + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("content-type", "application/xml".parse().unwrap()); + + Ok(ProxyResult { + status: 200, + headers: resp_headers, + body: ProxyResponseBody::Bytes(Bytes::from(xml)), + }) + } + + /// Execute a multipart operation via raw signed HTTP. + async fn execute_multipart( + &self, + pending: &PendingRequest, + body: Bytes, + ) -> Result { + let backend_url = build_backend_url(&pending.bucket_config, &pending.operation)?; + + tracing::debug!(backend_url = %backend_url, "multipart via raw HTTP"); + + let mut headers = HeaderMap::new(); + + // Forward relevant headers + for header_name in &["content-type", "content-length", "content-md5"] { + if let Some(val) = pending.original_headers.get(*header_name) { + headers.insert(*header_name, val.clone()); + } + } + + let payload_hash = if body.is_empty() { + UNSIGNED_PAYLOAD.to_string() + } else { + hash_payload(&body) + }; + + sign_s3_request( + &pending.method, + &backend_url, + &mut headers, + &pending.bucket_config, + &payload_hash, + )?; + + let raw_resp = self + .backend + .send_raw(pending.method.clone(), backend_url, headers, body) + .await?; + + tracing::debug!(status = raw_resp.status, "multipart backend response"); + + Ok(ProxyResult { + status: raw_resp.status, + headers: raw_resp.headers, + body: ProxyResponseBody::from_bytes(raw_resp.body), + }) + } +} + +/// The result of handling a proxy request. +pub struct ProxyResult { + pub status: u16, + pub headers: HeaderMap, + pub body: ProxyResponseBody, +} + +/// Headers to forward from backend responses (used by runtimes for Forward responses). +pub const RESPONSE_HEADER_ALLOWLIST: &[&str] = &[ + "content-type", + "content-length", + "content-range", + "etag", + "last-modified", + "accept-ranges", + "content-encoding", + "content-disposition", + "cache-control", + "x-amz-request-id", + "x-amz-version-id", + "location", +]; + +fn error_response(err: &ProxyError, resource: &str, request_id: &str, debug: bool) -> ProxyResult { + let xml = ErrorResponse::from_proxy_error(err, resource, request_id, debug).to_xml(); + let body = ProxyResponseBody::from_bytes(Bytes::from(xml)); + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/xml".parse().unwrap()); + + ProxyResult { + status: err.status_code(), + headers, + body, + } +} + +/// Sign an outbound S3 request using credentials from the bucket config. +/// +/// Used for multipart operations only. CRUD operations use presigned URLs. +fn sign_s3_request( + method: &Method, + url: &str, + headers: &mut HeaderMap, + config: &BucketConfig, + payload_hash: &str, +) -> Result<(), ProxyError> { + let access_key = config.option("access_key_id").unwrap_or(""); + let secret_key = config.option("secret_access_key").unwrap_or(""); + let region = config.option("region").unwrap_or("us-east-1"); + let has_credentials = !access_key.is_empty() && !secret_key.is_empty(); + + let parsed_url = + Url::parse(url).map_err(|e| ProxyError::Internal(format!("invalid backend URL: {}", e)))?; + + if has_credentials { + let session_token = config.option("token").map(|s| s.to_string()); + let signer = S3RequestSigner::new( + access_key.to_string(), + secret_key.to_string(), + region.to_string(), + session_token, + ); + signer.sign_request(method, &parsed_url, headers, payload_hash)?; + } else { + let host = parsed_url + .host_str() + .ok_or_else(|| ProxyError::Internal("no host in URL".into()))?; + let host_header = if let Some(port) = parsed_url.port() { + format!("{}:{}", host, port) + } else { + host.to_string() + }; + headers.insert("host", host_header.parse().unwrap()); + } + + Ok(()) +} + +/// Build an object_store Path from a bucket config and client-visible key. +fn build_object_path(config: &BucketConfig, key: &str) -> object_store::path::Path { + match &config.backend_prefix { + Some(prefix) => { + let p = prefix.trim_end_matches('/'); + if p.is_empty() { + object_store::path::Path::from(key) + } else { + let mut full_key = String::with_capacity(p.len() + 1 + key.len()); + full_key.push_str(p); + full_key.push('/'); + full_key.push_str(key); + object_store::path::Path::from(full_key) + } + } + None => object_store::path::Path::from(key), + } +} + +/// Build the full list prefix including backend_prefix. +fn build_list_prefix(config: &BucketConfig, client_prefix: &str) -> String { + match &config.backend_prefix { + Some(prefix) => { + let bp = prefix.trim_end_matches('/'); + if bp.is_empty() { + client_prefix.to_string() + } else { + let mut full_prefix = String::with_capacity(bp.len() + 1 + client_prefix.len()); + full_prefix.push_str(bp); + full_prefix.push('/'); + full_prefix.push_str(client_prefix); + full_prefix + } + } + None => client_prefix.to_string(), + } +} + +/// Parameters for building the S3 ListObjectsV2 XML response. +struct ListXmlParams<'a> { + bucket_name: &'a str, + client_prefix: &'a str, + delimiter: &'a str, + max_keys: usize, + is_truncated: bool, + key_count: usize, + start_after: &'a Option, + continuation_token: &'a Option, + next_continuation_token: Option, +} + +/// Build S3 ListObjectsV2 XML from an object_store ListResult. +/// +/// Pagination is handled by the backend — `is_truncated` and +/// `next_continuation_token` are passed through from the backend's response. +fn build_list_xml( + params: &ListXmlParams<'_>, + list_result: &object_store::ListResult, + config: &BucketConfig, + list_rewrite: Option<&ListRewrite>, +) -> Result { + let backend_prefix = config + .backend_prefix + .as_deref() + .unwrap_or("") + .trim_end_matches('/'); + let strip_prefix = if backend_prefix.is_empty() { + String::new() + } else { + format!("{}/", backend_prefix) + }; + + let contents: Vec = list_result + .objects + .iter() + .map(|obj| { + let raw_key = obj.location.to_string(); + ListContents { + key: rewrite_key(&raw_key, &strip_prefix, list_rewrite), + last_modified: obj + .last_modified + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(), + etag: obj.e_tag.as_deref().unwrap_or("\"\"").to_string(), + size: obj.size, + storage_class: "STANDARD", + } + }) + .collect(); + + let common_prefixes: Vec = list_result + .common_prefixes + .iter() + .map(|p| { + let raw_prefix = format!("{}/", p); + ListCommonPrefix { + prefix: rewrite_key(&raw_prefix, &strip_prefix, list_rewrite), + } + }) + .collect(); + + Ok(ListBucketResult { + xmlns: "http://s3.amazonaws.com/doc/2006-03-01/", + name: params.bucket_name.to_string(), + prefix: params.client_prefix.to_string(), + delimiter: params.delimiter.to_string(), + max_keys: params.max_keys, + is_truncated: params.is_truncated, + key_count: params.key_count, + start_after: params.start_after.clone(), + continuation_token: params.continuation_token.clone(), + next_continuation_token: params.next_continuation_token.clone(), + contents, + common_prefixes, + } + .to_xml()) +} + +/// Apply strip/add prefix rewriting to a key or prefix value. +/// +/// Works with `&str` slices to avoid intermediate allocations — only allocates +/// the final `String` once. +fn rewrite_key(raw: &str, strip_prefix: &str, list_rewrite: Option<&ListRewrite>) -> String { + // Strip the backend prefix (borrow from `raw`, no allocation) + let key = if !strip_prefix.is_empty() { + raw.strip_prefix(strip_prefix).unwrap_or(raw) + } else { + raw + }; + + // Apply list_rewrite if present + if let Some(rewrite) = list_rewrite { + let key = if !rewrite.strip_prefix.is_empty() { + key.strip_prefix(rewrite.strip_prefix.as_str()) + .unwrap_or(key) + } else { + key + }; + + if !rewrite.add_prefix.is_empty() { + // Must allocate for add_prefix — early return + return if key.is_empty() || key.starts_with('/') { + format!("{}{}", rewrite.add_prefix, key) + } else { + format!("{}/{}", rewrite.add_prefix, key) + }; + } + + return key.to_string(); + } + + key.to_string() +} + +/// All query parameters needed for a LIST operation, parsed in a single pass. +struct ListQueryParams { + prefix: String, + delimiter: String, + max_keys: usize, + continuation_token: Option, + start_after: Option, +} + +/// Parse prefix, delimiter, and pagination params from a LIST query string in one pass. +fn parse_list_query_params(raw_query: Option<&str>) -> ListQueryParams { + let mut prefix = None; + let mut delimiter = None; + let mut max_keys = None; + let mut continuation_token = None; + let mut start_after = None; + + if let Some(q) = raw_query { + for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { + match k.as_ref() { + "prefix" => prefix = Some(v.into_owned()), + "delimiter" => delimiter = Some(v.into_owned()), + "max-keys" => max_keys = Some(v.into_owned()), + "continuation-token" => continuation_token = Some(v.into_owned()), + "start-after" => start_after = Some(v.into_owned()), + _ => {} + } + } + } + + ListQueryParams { + prefix: prefix.unwrap_or_default(), + delimiter: delimiter.unwrap_or_else(|| "/".to_string()), + max_keys: max_keys + .and_then(|v| v.parse().ok()) + .unwrap_or(1000) + .min(1000), + continuation_token, + start_after, + } +} + +/// Build the backend URL for an S3 operation. +/// +/// Used for multipart operations that go through raw signed HTTP. +pub fn build_backend_url( + config: &BucketConfig, + operation: &S3Operation, +) -> Result { + let endpoint = config.option("endpoint").unwrap_or(""); + let base = endpoint.trim_end_matches('/'); + let bucket = config.option("bucket_name").unwrap_or(""); + let bucket_is_empty = bucket.is_empty(); + + let mut key = String::new(); + if let Some(prefix) = &config.backend_prefix { + key.push_str(prefix.trim_end_matches('/')); + key.push('/'); + } + key.push_str(operation.key()); + + let mut url = if bucket_is_empty { + format!("{}/{}", base, key) + } else { + format!("{}/{}/{}", base, bucket, key) + }; + + match operation { + S3Operation::CreateMultipartUpload { .. } => { + url.push_str("?uploads"); + } + S3Operation::UploadPart { + upload_id, + part_number, + .. + } => { + let qs = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("partNumber", &part_number.to_string()) + .append_pair("uploadId", upload_id) + .finish(); + url.push('?'); + url.push_str(&qs); + } + S3Operation::CompleteMultipartUpload { upload_id, .. } + | S3Operation::AbortMultipartUpload { upload_id, .. } => { + let qs = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("uploadId", upload_id) + .finish(); + url.push('?'); + url.push_str(&qs); + } + _ => {} + } + + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn test_bucket_config() -> BucketConfig { + let mut backend_options = HashMap::new(); + backend_options.insert( + "endpoint".into(), + "https://s3.us-east-1.amazonaws.com".into(), + ); + backend_options.insert("bucket_name".into(), "my-backend-bucket".into()); + BucketConfig { + name: "test".into(), + backend_type: "s3".into(), + backend_prefix: None, + anonymous_access: false, + allowed_roles: vec![], + backend_options, + } + } + + #[test] + fn upload_id_with_special_chars_is_encoded() { + let config = test_bucket_config(); + let malicious_upload_id = "abc&x-amz-acl=public-read&foo=bar"; + let op = S3Operation::UploadPart { + bucket: "test".into(), + key: "file.bin".into(), + upload_id: malicious_upload_id.into(), + part_number: 1, + }; + + let url = build_backend_url(&config, &op).unwrap(); + + // The & and = characters in upload_id must be percent-encoded so they + // cannot act as query parameter separators/assignments. + let query = url.split_once('?').unwrap().1; + let params: Vec<(String, String)> = url::form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + // Should be exactly 2 params: partNumber and uploadId + assert_eq!( + params.len(), + 2, + "expected 2 query params, got: {:?}", + params + ); + assert!(params.iter().any(|(k, v)| k == "partNumber" && v == "1")); + assert!(params + .iter() + .any(|(k, v)| k == "uploadId" && v == malicious_upload_id)); + } + + #[test] + fn upload_id_encoded_in_complete_multipart() { + let config = test_bucket_config(); + let op = S3Operation::CompleteMultipartUpload { + bucket: "test".into(), + key: "file.bin".into(), + upload_id: "id&injected=true".into(), + }; + + let url = build_backend_url(&config, &op).unwrap(); + + assert!( + !url.contains("injected=true"), + "upload_id was not encoded: {}", + url + ); + } + + #[test] + fn normal_upload_id_works() { + let config = test_bucket_config(); + let op = S3Operation::UploadPart { + bucket: "test".into(), + key: "file.bin".into(), + upload_id: "2~abcdef1234567890".into(), + part_number: 3, + }; + + let url = build_backend_url(&config, &op).unwrap(); + + assert!(url.starts_with("https://s3.us-east-1.amazonaws.com/my-backend-bucket/file.bin?")); + assert!(url.contains("partNumber=3")); + assert!( + url.contains("uploadId=2~abcdef1234567890") + || url.contains("uploadId=2%7Eabcdef1234567890") + ); + } +} diff --git a/crates/libs/core/src/resolver.rs b/crates/libs/core/src/resolver.rs new file mode 100644 index 0000000..ee3b977 --- /dev/null +++ b/crates/libs/core/src/resolver.rs @@ -0,0 +1,183 @@ +//! Request resolution abstraction. +//! +//! The [`RequestResolver`] trait decouples "what to do with a request" from +//! the proxy handler itself. Each product (static config, Source Cooperative, +//! etc.) implements its own resolver. The proxy handler simply calls +//! `resolver.resolve()` and acts on the [`ResolvedAction`]. + +use crate::auth; +use crate::config::ConfigProvider; +use crate::error::ProxyError; +use crate::maybe_send::{MaybeSend, MaybeSync}; +use crate::s3::request::{self, HostStyle}; +use crate::s3::response::{BucketEntry, BucketList, BucketOwner, ListAllMyBucketsResult}; +use crate::sealed_token::TokenKey; +use crate::types::{BucketConfig, S3Operation}; +use bytes::Bytes; +use http::{HeaderMap, Method}; +use std::future::Future; + +/// Trait for resolving an incoming request into an action the proxy should take. +/// +/// Implementations encapsulate namespace mapping, authentication, authorization, +/// and any request rewriting logic specific to a product or deployment mode. +pub trait RequestResolver: Clone + MaybeSend + MaybeSync + 'static { + fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> impl Future> + MaybeSend; +} + +/// The action the proxy handler should take after resolution. +pub enum ResolvedAction { + /// Forward the request to a backend. Core handles URL building, signing, streaming. + Proxy { + operation: S3Operation, + bucket_config: BucketConfig, + /// Optional rewrite rule for list response XML. + list_rewrite: Option, + }, + /// Return a synthetic response directly (small XML, never a stream). + Response { + status: u16, + headers: HeaderMap, + body: Bytes, + }, +} + +/// Describes how to rewrite `` and `` values in list response XML. +#[derive(Debug, Clone)] +pub struct ListRewrite { + /// Prefix to strip from the beginning of values. + pub strip_prefix: String, + /// Prefix to add after stripping. + pub add_prefix: String, +} + +/// Default resolver backed by a [`ConfigProvider`]. +/// +/// Extracts the S3 operation from the request, looks up the bucket in the +/// config, authenticates and authorizes, then returns a [`ResolvedAction::Proxy`]. +/// `ListBuckets` is handled as a synthetic [`ResolvedAction::Response`]. +#[derive(Clone)] +pub struct DefaultResolver

{ + config: P, + virtual_host_domain: Option, + token_key: Option, +} + +impl

DefaultResolver

{ + pub fn new( + config: P, + virtual_host_domain: Option, + token_key: Option, + ) -> Self { + Self { + config, + virtual_host_domain, + token_key, + } + } +} + +impl RequestResolver for DefaultResolver

{ + async fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> Result { + // Determine host style + let host_style = determine_host_style(headers, self.virtual_host_domain.as_deref()); + + // Parse the S3 operation + let operation = request::parse_s3_request(method, path, query, headers, host_style)?; + tracing::debug!(operation = ?operation, "parsed S3 operation"); + + // Handle ListBuckets — returns virtual bucket list from config, no backend call + if matches!(operation, S3Operation::ListBuckets) { + let buckets = self.config.list_buckets().await?; + tracing::info!(count = buckets.len(), "listing virtual buckets"); + let xml = ListAllMyBucketsResult { + owner: BucketOwner { + id: "s3-proxy".to_string(), + display_name: "s3-proxy".to_string(), + }, + buckets: BucketList { + buckets: buckets + .iter() + .map(|b| BucketEntry { + name: b.name.clone(), + creation_date: "2024-01-01T00:00:00.000Z".to_string(), + }) + .collect(), + }, + } + .to_xml(); + + let mut resp_headers = HeaderMap::new(); + resp_headers.insert("content-type", "application/xml".parse().unwrap()); + return Ok(ResolvedAction::Response { + status: 200, + headers: resp_headers, + body: Bytes::from(xml), + }); + } + + // Get bucket name and look up config + let bucket_name = operation + .bucket() + .ok_or_else(|| ProxyError::InvalidRequest("no bucket in request".into()))?; + + let bucket_config = self.config.get_bucket(bucket_name).await?.ok_or_else(|| { + tracing::warn!(bucket = %bucket_name, "bucket not found in config"); + ProxyError::BucketNotFound(bucket_name.to_string()) + })?; + + tracing::debug!( + bucket = %bucket_name, + backend_type = %bucket_config.backend_type, + "resolved bucket config" + ); + + // Authenticate + let identity = auth::resolve_identity( + method, + path, + query.unwrap_or(""), + headers, + &self.config, + self.token_key.as_ref(), + ) + .await?; + tracing::debug!(identity = ?identity, "resolved identity"); + + // Authorize + auth::authorize(&identity, &operation, &bucket_config)?; + tracing::trace!("authorization passed"); + + Ok(ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite: None, + }) + } +} + +fn determine_host_style(headers: &HeaderMap, virtual_host_domain: Option<&str>) -> HostStyle { + if let Some(domain) = virtual_host_domain { + if let Some(host) = headers.get("host").and_then(|v| v.to_str().ok()) { + let host = host.split(':').next().unwrap_or(host); + if let Some(bucket) = host.strip_suffix(&format!(".{}", domain)) { + return HostStyle::VirtualHosted { + bucket: bucket.to_string(), + }; + } + } + } + HostStyle::Path +} diff --git a/crates/libs/core/src/response_body.rs b/crates/libs/core/src/response_body.rs new file mode 100644 index 0000000..48b75a2 --- /dev/null +++ b/crates/libs/core/src/response_body.rs @@ -0,0 +1,35 @@ +//! Response body type for the proxy. +//! +//! [`ProxyResponseBody`] carries non-streaming response data. Streaming +//! responses (GET, PUT) are handled by the runtime via [`ForwardRequest`] +//! presigned URLs — the handler never touches those bytes. + +use bytes::Bytes; + +/// The body of a proxy response. +/// +/// Only used for responses the handler constructs directly (errors, LIST XML, +/// multipart XML, HEAD metadata). Streaming GET/PUT bodies bypass this type +/// entirely via the `Forward` action. +pub enum ProxyResponseBody { + /// Fixed bytes (error XML, list XML, multipart XML responses, etc.). + Bytes(Bytes), + /// Empty body (HEAD responses, etc.). + Empty, +} + +impl ProxyResponseBody { + /// Create a response body from raw bytes. + pub fn from_bytes(bytes: Bytes) -> Self { + if bytes.is_empty() { + Self::Empty + } else { + Self::Bytes(bytes) + } + } + + /// Create an empty response body. + pub fn empty() -> Self { + Self::Empty + } +} diff --git a/crates/libs/core/src/s3/list_rewrite.rs b/crates/libs/core/src/s3/list_rewrite.rs new file mode 100644 index 0000000..29cb80e --- /dev/null +++ b/crates/libs/core/src/s3/list_rewrite.rs @@ -0,0 +1,116 @@ +//! XML rewriting for S3 list responses. +//! +//! When a backend prefix is configured, the backend returns keys that include +//! the prefix. This module strips that prefix and optionally prepends a new +//! one, so clients see the expected key structure. + +use crate::resolver::ListRewrite; + +/// Rewrite `` and `` element values in a ListObjectsV2 XML response +/// according to the given [`ListRewrite`] rule. +pub fn rewrite_list_response(xml: &str, rewrite: &ListRewrite) -> String { + let mut result = xml.to_string(); + result = rewrite_xml_element_values(&result, "Key", &rewrite.strip_prefix, &rewrite.add_prefix); + result = rewrite_xml_element_values( + &result, + "Prefix", + &rewrite.strip_prefix, + &rewrite.add_prefix, + ); + result +} + +/// Replace prefix in XML element values: +/// `old_prefix/rest` -> `new_prefix/rest` +fn rewrite_xml_element_values(xml: &str, tag: &str, old_prefix: &str, new_prefix: &str) -> String { + let open = format!("<{}>", tag); + let close = format!("", tag); + let mut result = String::with_capacity(xml.len()); + let mut remaining = xml; + + while let Some(start_idx) = remaining.find(&open) { + result.push_str(&remaining[..start_idx + open.len()]); + remaining = &remaining[start_idx + open.len()..]; + + if let Some(end_idx) = remaining.find(&close) { + let value = &remaining[..end_idx]; + if let Some(stripped) = value.strip_prefix(old_prefix) { + if new_prefix.is_empty() { + result.push_str(stripped.trim_start_matches('/')); + } else { + result.push_str(new_prefix); + if !stripped.is_empty() && !stripped.starts_with('/') { + result.push('/'); + } + result.push_str(stripped.trim_start_matches('/')); + } + } else { + result.push_str(value); + } + result.push_str(&close); + remaining = &remaining[end_idx + close.len()..]; + } else { + // Malformed XML — just append the rest + break; + } + } + result.push_str(remaining); + result +} + +/// Extract the text content of the first occurrence of `...`. +pub fn extract_xml_element<'a>(xml: &'a str, tag: &str) -> Option<&'a str> { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)? + open.len(); + let end = xml[start..].find(&close)? + start; + Some(&xml[start..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rewrite_strips_prefix() { + let xml = r#"base/mirror/file.csv"#; + let rewrite = ListRewrite { + strip_prefix: "base/mirror/".to_string(), + add_prefix: "repo".to_string(), + }; + let result = rewrite_list_response(xml, &rewrite); + assert!( + result.contains("repo/file.csv"), + "got: {}", + result + ); + } + + #[test] + fn test_rewrite_strip_only() { + let xml = r#"prefix/file.csv"#; + let rewrite = ListRewrite { + strip_prefix: "prefix/".to_string(), + add_prefix: String::new(), + }; + let result = rewrite_list_response(xml, &rewrite); + assert!(result.contains("file.csv")); + } + + #[test] + fn test_extract_xml_element() { + let xml = r#"some/prefix/"#; + assert_eq!(extract_xml_element(xml, "Prefix"), Some("some/prefix/")); + } + + #[test] + fn test_no_match_preserves_xml() { + let xml = r#"other/file.csv"#; + let rewrite = ListRewrite { + strip_prefix: "nonexistent/".to_string(), + add_prefix: "new/".to_string(), + }; + let result = rewrite_list_response(xml, &rewrite); + assert!(result.contains("other/file.csv")); + } +} diff --git a/crates/libs/core/src/s3/mod.rs b/crates/libs/core/src/s3/mod.rs new file mode 100644 index 0000000..9540299 --- /dev/null +++ b/crates/libs/core/src/s3/mod.rs @@ -0,0 +1,4 @@ +pub mod list_rewrite; +pub mod pagination; +pub mod request; +pub mod response; diff --git a/crates/libs/core/src/s3/pagination.rs b/crates/libs/core/src/s3/pagination.rs new file mode 100644 index 0000000..d3d765a --- /dev/null +++ b/crates/libs/core/src/s3/pagination.rs @@ -0,0 +1,342 @@ +//! S3 ListObjectsV2 pagination as a post-processing step. +//! +//! `object_store::list_with_delimiter()` always fetches all results. +//! This module applies `max-keys`, `continuation-token`, and `start-after` +//! filtering on the full result set to produce S3-compliant paginated responses. + +use base64::Engine; + +use crate::error::ProxyError; +use crate::s3::response::{ListCommonPrefix, ListContents}; + +const DEFAULT_MAX_KEYS: usize = 1000; +const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +pub struct PaginationParams { + pub max_keys: usize, + pub continuation_token: Option, + pub start_after: Option, +} + +pub struct PaginatedList { + pub contents: Vec, + pub common_prefixes: Vec, + pub is_truncated: bool, + pub next_continuation_token: Option, +} + +/// Parse `max-keys`, `continuation-token`, and `start-after` from a query string. +pub fn parse_pagination_params(raw_query: Option<&str>) -> PaginationParams { + let pairs = url::form_urlencoded::parse(raw_query.unwrap_or("").as_bytes()); + let find = |name| { + pairs + .clone() + .find(|(k, _)| k == name) + .map(|(_, v)| v.to_string()) + }; + + PaginationParams { + max_keys: find("max-keys") + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_MAX_KEYS) + .min(DEFAULT_MAX_KEYS), + continuation_token: find("continuation-token"), + start_after: find("start-after"), + } +} + +enum Entry { + Object(ListContents), + Prefix(ListCommonPrefix), +} + +impl Entry { + fn key(&self) -> &str { + match self { + Self::Object(c) => &c.key, + Self::Prefix(p) => &p.prefix, + } + } +} + +/// Apply pagination to a full list of objects and common prefixes. +pub fn paginate( + contents: Vec, + common_prefixes: Vec, + params: &PaginationParams, +) -> Result { + // Decode continuation token (takes precedence over start-after per S3 spec) + let decoded_token = params + .continuation_token + .as_deref() + .map(|t| { + B64.decode(t) + .ok() + .and_then(|b| String::from_utf8(b).ok()) + .ok_or_else(|| ProxyError::InvalidRequest("invalid continuation token".into())) + }) + .transpose()?; + + let start_after = decoded_token.as_deref().or(params.start_after.as_deref()); + + // Merge into a single sorted list + let mut entries: Vec = contents + .into_iter() + .map(Entry::Object) + .chain(common_prefixes.into_iter().map(Entry::Prefix)) + .collect(); + entries.sort_by(|a, b| a.key().cmp(b.key())); + + // Filter by start-after, then take max_keys + 1 to detect truncation + let mut page: Vec = match start_after { + Some(s) => entries.into_iter().filter(|e| e.key() > s).collect(), + None => entries, + }; + + let is_truncated = page.len() > params.max_keys; + page.truncate(params.max_keys); + + let next_continuation_token = if is_truncated { + page.last().map(|e| B64.encode(e.key())) + } else { + None + }; + + // Split back into contents and common_prefixes + let mut result_contents = Vec::new(); + let mut result_prefixes = Vec::new(); + for entry in page { + match entry { + Entry::Object(c) => result_contents.push(c), + Entry::Prefix(p) => result_prefixes.push(p), + } + } + + Ok(PaginatedList { + contents: result_contents, + common_prefixes: result_prefixes, + is_truncated, + next_continuation_token, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_contents(keys: &[&str]) -> Vec { + keys.iter() + .map(|k| ListContents { + key: k.to_string(), + last_modified: "2024-01-01T00:00:00.000Z".to_string(), + etag: "\"abc\"".to_string(), + size: 100, + storage_class: "STANDARD", + }) + .collect() + } + + fn make_prefixes(prefixes: &[&str]) -> Vec { + prefixes + .iter() + .map(|p| ListCommonPrefix { + prefix: p.to_string(), + }) + .collect() + } + + #[test] + fn parse_defaults() { + let p = parse_pagination_params(None); + assert_eq!(p.max_keys, 1000); + assert!(p.continuation_token.is_none()); + assert!(p.start_after.is_none()); + } + + #[test] + fn parse_max_keys_clamped_to_1000() { + assert_eq!(parse_pagination_params(Some("max-keys=5")).max_keys, 5); + assert_eq!( + parse_pagination_params(Some("max-keys=9999")).max_keys, + 1000 + ); + assert_eq!(parse_pagination_params(Some("max-keys=abc")).max_keys, 1000); + } + + #[test] + fn parse_all_params() { + let token = B64.encode("some-key"); + let q = format!("max-keys=2&continuation-token={}&start-after=aaa", token); + let p = parse_pagination_params(Some(&q)); + assert_eq!(p.max_keys, 2); + assert_eq!(p.continuation_token.as_deref(), Some(token.as_str())); + assert_eq!(p.start_after.as_deref(), Some("aaa")); + } + + #[test] + fn no_truncation() { + let r = paginate( + make_contents(&["a", "b", "c"]), + vec![], + &PaginationParams { + max_keys: 1000, + continuation_token: None, + start_after: None, + }, + ) + .unwrap(); + assert_eq!(r.contents.len(), 3); + assert!(!r.is_truncated); + assert!(r.next_continuation_token.is_none()); + } + + #[test] + fn truncation_and_token() { + let r = paginate( + make_contents(&["a", "b", "c", "d", "e"]), + vec![], + &PaginationParams { + max_keys: 2, + continuation_token: None, + start_after: None, + }, + ) + .unwrap(); + assert_eq!(r.contents.len(), 2); + assert!(r.is_truncated); + assert_eq!(r.contents[0].key, "a"); + assert_eq!(r.contents[1].key, "b"); + assert!(r.next_continuation_token.is_some()); + } + + #[test] + fn continuation_token_round_trip() { + let items = make_contents(&["a", "b", "c", "d", "e"]); + let mk = |token| PaginationParams { + max_keys: 2, + continuation_token: token, + start_after: None, + }; + + let p1 = paginate(items.clone(), vec![], &mk(None)).unwrap(); + assert_eq!( + p1.contents.iter().map(|c| &c.key).collect::>(), + &["a", "b"] + ); + + let p2 = paginate(items.clone(), vec![], &mk(p1.next_continuation_token)).unwrap(); + assert_eq!( + p2.contents.iter().map(|c| &c.key).collect::>(), + &["c", "d"] + ); + + let p3 = paginate(items.clone(), vec![], &mk(p2.next_continuation_token)).unwrap(); + assert_eq!( + p3.contents.iter().map(|c| &c.key).collect::>(), + &["e"] + ); + assert!(!p3.is_truncated); + } + + #[test] + fn start_after() { + let r = paginate( + make_contents(&["a", "b", "c", "d"]), + vec![], + &PaginationParams { + max_keys: 1000, + continuation_token: None, + start_after: Some("b".into()), + }, + ) + .unwrap(); + assert_eq!( + r.contents.iter().map(|c| &c.key).collect::>(), + &["c", "d"] + ); + } + + #[test] + fn continuation_token_overrides_start_after() { + let r = paginate( + make_contents(&["a", "b", "c", "d", "e"]), + vec![], + &PaginationParams { + max_keys: 1000, + continuation_token: Some(B64.encode("c")), + start_after: Some("a".into()), + }, + ) + .unwrap(); + assert_eq!( + r.contents.iter().map(|c| &c.key).collect::>(), + &["d", "e"] + ); + } + + #[test] + fn interleaved_objects_and_prefixes() { + let r = paginate( + make_contents(&["a.txt", "c.txt"]), + make_prefixes(&["b/", "d/"]), + &PaginationParams { + max_keys: 3, + continuation_token: None, + start_after: None, + }, + ) + .unwrap(); + assert_eq!(r.contents.len(), 2); + assert_eq!(r.common_prefixes.len(), 1); + assert_eq!(r.contents[0].key, "a.txt"); + assert_eq!(r.common_prefixes[0].prefix, "b/"); + assert_eq!(r.contents[1].key, "c.txt"); + assert!(r.is_truncated); + } + + #[test] + fn invalid_token_returns_error() { + let r = paginate( + make_contents(&["a"]), + vec![], + &PaginationParams { + max_keys: 1000, + continuation_token: Some("not-valid!!!".into()), + start_after: None, + }, + ); + assert!(r.is_err()); + } + + #[test] + fn max_keys_zero() { + let r = paginate( + make_contents(&["a", "b"]), + vec![], + &PaginationParams { + max_keys: 0, + continuation_token: None, + start_after: None, + }, + ) + .unwrap(); + assert!(r.contents.is_empty()); + assert!(r.is_truncated); + } + + #[test] + fn empty_input() { + let r = paginate( + vec![], + vec![], + &PaginationParams { + max_keys: 1000, + continuation_token: None, + start_after: None, + }, + ) + .unwrap(); + assert_eq!(r.contents.len(), 0); + assert!(!r.is_truncated); + } +} diff --git a/crates/libs/core/src/s3/request.rs b/crates/libs/core/src/s3/request.rs new file mode 100644 index 0000000..d26dd0a --- /dev/null +++ b/crates/libs/core/src/s3/request.rs @@ -0,0 +1,156 @@ +//! Parse incoming HTTP requests into typed S3 operations. + +use crate::error::ProxyError; +use crate::types::S3Operation; +use http::Method; + +/// Extract the bucket and key from a path-style S3 request. +/// +/// Path-style: `/{bucket}/{key}` +/// Virtual-hosted-style: Host header `{bucket}.s3.example.com` with path `/{key}` +pub fn parse_s3_request( + method: &Method, + uri_path: &str, + query: Option<&str>, + _headers: &http::HeaderMap, + host_style: HostStyle, +) -> Result { + // GET / with path-style → ListBuckets (no bucket in path) + if matches!(host_style, HostStyle::Path) && uri_path.trim_start_matches('/').is_empty() { + if *method == Method::GET { + return Ok(S3Operation::ListBuckets); + } + return Err(ProxyError::InvalidRequest( + "unsupported operation on /".into(), + )); + } + + let (bucket, key) = match host_style { + HostStyle::Path => parse_path_style(uri_path)?, + HostStyle::VirtualHosted { bucket } => { + (bucket, uri_path.trim_start_matches('/').to_string()) + } + }; + + build_s3_operation(method, bucket, key, query) +} + +/// Build an [`S3Operation`] from an already-extracted bucket, key, and query. +/// +/// This is used by both [`parse_s3_request`] and custom resolvers that parse +/// the path themselves (e.g., Source Cooperative). +pub fn build_s3_operation( + method: &Method, + bucket: String, + key: String, + query: Option<&str>, +) -> Result { + let query_params = parse_query_params(query); + + // Check for multipart upload query params + let upload_id = query_params + .iter() + .find(|(k, _)| k == "uploadId") + .map(|(_, v)| v.clone()); + + let has_uploads = query_params.iter().any(|(k, _)| k == "uploads"); + + match *method { + Method::GET => { + if key.is_empty() { + // ListBucket — pass the raw query string through so the proxy + // can forward all list params (prefix, delimiter, max-keys, + // continuation-token, list-type, start-after, etc.) to the backend. + Ok(S3Operation::ListBucket { + bucket, + raw_query: query.map(|q| q.to_string()), + }) + } else { + Ok(S3Operation::GetObject { bucket, key }) + } + } + Method::HEAD => Ok(S3Operation::HeadObject { bucket, key }), + Method::PUT => { + if let Some(upload_id) = upload_id { + let part_number = query_params + .iter() + .find(|(k, _)| k == "partNumber") + .and_then(|(_, v)| v.parse().ok()) + .ok_or_else(|| ProxyError::InvalidRequest("missing partNumber".into()))?; + + Ok(S3Operation::UploadPart { + bucket, + key, + upload_id, + part_number, + }) + } else { + Ok(S3Operation::PutObject { bucket, key }) + } + } + Method::POST => { + if has_uploads { + Ok(S3Operation::CreateMultipartUpload { bucket, key }) + } else if let Some(upload_id) = upload_id { + Ok(S3Operation::CompleteMultipartUpload { + bucket, + key, + upload_id, + }) + } else { + Err(ProxyError::InvalidRequest( + "unsupported POST operation".into(), + )) + } + } + Method::DELETE => { + if let Some(upload_id) = upload_id { + Ok(S3Operation::AbortMultipartUpload { + bucket, + key, + upload_id, + }) + } else if !key.is_empty() { + Ok(S3Operation::DeleteObject { bucket, key }) + } else { + Err(ProxyError::InvalidRequest( + "unsupported DELETE operation".into(), + )) + } + } + _ => Err(ProxyError::InvalidRequest(format!( + "unsupported method: {}", + method + ))), + } +} + +#[derive(Debug, Clone)] +pub enum HostStyle { + /// Path-style: `/{bucket}/{key}` + Path, + /// Virtual-hosted-style: bucket extracted from Host header. + VirtualHosted { bucket: String }, +} + +fn parse_path_style(path: &str) -> Result<(String, String), ProxyError> { + let trimmed = path.trim_start_matches('/'); + if trimmed.is_empty() { + return Err(ProxyError::InvalidRequest("empty path".into())); + } + + match trimmed.split_once('/') { + Some((bucket, key)) => Ok((bucket.to_string(), key.to_string())), + None => Ok((trimmed.to_string(), String::new())), + } +} + +fn parse_query_params(query: Option<&str>) -> Vec<(String, String)> { + query + .map(|q| { + url::form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/libs/core/src/s3/response.rs b/crates/libs/core/src/s3/response.rs new file mode 100644 index 0000000..43143df --- /dev/null +++ b/crates/libs/core/src/s3/response.rs @@ -0,0 +1,281 @@ +//! S3 XML response serialization. + +use quick_xml::se::to_string as xml_to_string; +use serde::Serialize; + +use crate::error::ProxyError; + +/// S3 Error response XML. +#[derive(Debug, Serialize)] +#[serde(rename = "Error")] +pub struct ErrorResponse { + #[serde(rename = "Code")] + pub code: String, + #[serde(rename = "Message")] + pub message: String, + #[serde(rename = "Resource")] + pub resource: String, + #[serde(rename = "RequestId")] + pub request_id: String, +} + +impl ErrorResponse { + /// Build an S3-compatible error response. + /// + /// When `debug` is `true`, the full internal error message is included + /// (useful during development). When `false`, server-side errors (500) + /// use a generic message to avoid leaking backend details. + pub fn from_proxy_error( + err: &ProxyError, + resource: &str, + request_id: &str, + debug: bool, + ) -> Self { + let message = if debug { + err.to_string() + } else { + err.safe_message() + }; + Self { + code: err.s3_error_code().to_string(), + message, + resource: resource.to_string(), + request_id: request_id.to_string(), + } + } + + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self) + .unwrap_or_else(|_| "InternalError".to_string()) + ) + } +} + +/// InitiateMultipartUpload response. +#[derive(Debug, Serialize)] +#[serde(rename = "InitiateMultipartUploadResult")] +pub struct InitiateMultipartUploadResult { + #[serde(rename = "Bucket")] + pub bucket: String, + #[serde(rename = "Key")] + pub key: String, + #[serde(rename = "UploadId")] + pub upload_id: String, +} + +impl InitiateMultipartUploadResult { + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self).unwrap_or_default() + ) + } +} + +/// CompleteMultipartUpload response. +#[derive(Debug, Serialize)] +#[serde(rename = "CompleteMultipartUploadResult")] +pub struct CompleteMultipartUploadResult { + #[serde(rename = "Location")] + pub location: String, + #[serde(rename = "Bucket")] + pub bucket: String, + #[serde(rename = "Key")] + pub key: String, + #[serde(rename = "ETag")] + pub etag: String, +} + +impl CompleteMultipartUploadResult { + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self).unwrap_or_default() + ) + } +} + +/// Request body for CompleteMultipartUpload. +#[derive(Debug, serde::Deserialize)] +#[serde(rename = "CompleteMultipartUpload")] +pub struct CompleteMultipartUploadRequest { + #[serde(rename = "Part")] + pub parts: Vec, +} + +#[derive(Debug, serde::Deserialize)] +pub struct CompletePart { + #[serde(rename = "PartNumber")] + pub part_number: u32, + #[serde(rename = "ETag")] + pub etag: String, +} + +/// ListAllMyBucketsResult response (for `GET /`). +#[derive(Debug, Serialize)] +#[serde(rename = "ListAllMyBucketsResult")] +pub struct ListAllMyBucketsResult { + #[serde(rename = "Owner")] + pub owner: BucketOwner, + #[serde(rename = "Buckets")] + pub buckets: BucketList, +} + +#[derive(Debug, Serialize)] +pub struct BucketOwner { + #[serde(rename = "ID")] + pub id: String, + #[serde(rename = "DisplayName")] + pub display_name: String, +} + +#[derive(Debug, Serialize)] +pub struct BucketList { + #[serde(rename = "Bucket")] + pub buckets: Vec, +} + +#[derive(Debug, Serialize)] +pub struct BucketEntry { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "CreationDate")] + pub creation_date: String, +} + +impl ListAllMyBucketsResult { + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self).unwrap_or_default() + ) + } +} + +/// S3 ListObjectsV2 response. +#[derive(Debug, Serialize)] +#[serde(rename = "ListBucketResult")] +pub struct ListBucketResult { + #[serde(rename = "@xmlns")] + pub xmlns: &'static str, + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "Prefix")] + pub prefix: String, + #[serde(rename = "Delimiter")] + pub delimiter: String, + #[serde(rename = "MaxKeys")] + pub max_keys: usize, + #[serde(rename = "IsTruncated")] + pub is_truncated: bool, + #[serde(rename = "KeyCount")] + pub key_count: usize, + #[serde(rename = "StartAfter", skip_serializing_if = "Option::is_none")] + pub start_after: Option, + #[serde(rename = "ContinuationToken", skip_serializing_if = "Option::is_none")] + pub continuation_token: Option, + #[serde( + rename = "NextContinuationToken", + skip_serializing_if = "Option::is_none" + )] + pub next_continuation_token: Option, + #[serde(rename = "Contents", default)] + pub contents: Vec, + #[serde(rename = "CommonPrefixes", default)] + pub common_prefixes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ListContents { + #[serde(rename = "Key")] + pub key: String, + #[serde(rename = "LastModified")] + pub last_modified: String, + #[serde(rename = "ETag")] + pub etag: String, + #[serde(rename = "Size")] + pub size: u64, + #[serde(rename = "StorageClass")] + pub storage_class: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct ListCommonPrefix { + #[serde(rename = "Prefix")] + pub prefix: String, +} + +impl ListBucketResult { + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self).unwrap_or_default() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_list_bucket_result_xml() { + let result = ListBucketResult { + xmlns: "http://s3.amazonaws.com/doc/2006-03-01/", + name: "my-bucket".to_string(), + prefix: "photos/".to_string(), + delimiter: "/".to_string(), + max_keys: 1000, + is_truncated: false, + key_count: 1, + start_after: None, + continuation_token: None, + next_continuation_token: None, + contents: vec![ListContents { + key: "photos/image.jpg".to_string(), + last_modified: "2024-01-01T00:00:00.000Z".to_string(), + etag: "\"abc123\"".to_string(), + size: 1024, + storage_class: "STANDARD", + }], + common_prefixes: vec![ListCommonPrefix { + prefix: "photos/thumbs/".to_string(), + }], + }; + + let xml = result.to_xml(); + assert!(xml.starts_with("")); + assert!( + xml.contains("") + ); + assert!(xml.contains("my-bucket")); + assert!(xml.contains("photos/image.jpg")); + assert!(xml.contains("1024")); + assert!(xml.contains("photos/thumbs/")); + } + + #[test] + fn test_list_bucket_result_empty() { + let result = ListBucketResult { + xmlns: "http://s3.amazonaws.com/doc/2006-03-01/", + name: "bucket".to_string(), + prefix: String::new(), + delimiter: "/".to_string(), + max_keys: 1000, + is_truncated: false, + key_count: 0, + start_after: None, + continuation_token: None, + next_continuation_token: None, + contents: vec![], + common_prefixes: vec![], + }; + + let xml = result.to_xml(); + assert!(xml.contains("0")); + assert!(!xml.contains("")); + assert!(!xml.contains("")); + } +} diff --git a/crates/libs/core/src/sealed_token.rs b/crates/libs/core/src/sealed_token.rs new file mode 100644 index 0000000..17fca08 --- /dev/null +++ b/crates/libs/core/src/sealed_token.rs @@ -0,0 +1,181 @@ +//! Self-contained encrypted session tokens using AES-256-GCM. +//! +//! When a `TokenKey` is configured, temporary credentials are encrypted into +//! the session token itself. The proxy decrypts the token on each request — +//! no server-side storage lookup is needed. This is critical for stateless +//! runtimes like Cloudflare Workers where in-memory state does not persist +//! across invocations. +//! +//! ## Security properties +//! +//! - **Encryption**: AES-256-GCM provides authenticated encryption (confidentiality + integrity). +//! - **Nonce**: 12-byte random nonce per token via `OsRng` (96 bits, per GCM spec). +//! - **Token format**: `base64url(nonce[12] || ciphertext + GCM tag[16])`. +//! - **Expiration**: Enforced at unseal time — expired credentials return `Err`. +//! - **Scope binding**: Allowed scopes are sealed at mint time, so config changes only affect +//! newly minted credentials. +//! - **Key rotation**: Tokens sealed with an old key will fail to decrypt (`Ok(None)`), causing +//! the client to re-authenticate. No explicit revocation mechanism is needed. + +use crate::error::ProxyError; +use crate::types::TemporaryCredentials; +use aes_gcm::aead::{Aead, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, KeyInit}; +use base64::Engine; +use std::sync::Arc; + +const NONCE_LEN: usize = 12; + +/// Wraps an AES-256-GCM cipher for sealing/unsealing session tokens. +#[derive(Clone)] +pub struct TokenKey(Arc); + +impl TokenKey { + /// Create a `TokenKey` from a base64-encoded 32-byte key. + pub fn from_base64(encoded: &str) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .map_err(|e| { + ProxyError::ConfigError(format!("invalid SESSION_TOKEN_KEY base64: {e}")) + })?; + if bytes.len() != 32 { + return Err(ProxyError::ConfigError(format!( + "SESSION_TOKEN_KEY must be 32 bytes, got {}", + bytes.len() + ))); + } + let cipher = Aes256Gcm::new_from_slice(&bytes) + .map_err(|e| ProxyError::ConfigError(format!("AES key error: {e}")))?; + Ok(Self(Arc::new(cipher))) + } + + /// Encrypt `TemporaryCredentials` into a base64url token. + /// + /// Format: `base64url(nonce[12] || ciphertext+tag)` + pub fn seal(&self, creds: &TemporaryCredentials) -> Result { + let plaintext = serde_json::to_vec(creds) + .map_err(|e| ProxyError::Internal(format!("seal json: {e}")))?; + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = self + .0 + .encrypt(&nonce, plaintext.as_slice()) + .map_err(|e| ProxyError::Internal(format!("seal encrypt: {e}")))?; + + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&blob)) + } + + /// Decrypt a session token back into `TemporaryCredentials`. + /// + /// Returns `Ok(None)` if the token doesn't look like a sealed token + /// (e.g. base64 decode fails or decryption fails — allows fallback to + /// config-based lookup). Returns `Err(ExpiredCredentials)` when the + /// token decrypts successfully but the credentials have expired. + pub fn unseal(&self, token: &str) -> Result, ProxyError> { + let blob = match base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token) { + Ok(b) => b, + Err(_) => return Ok(None), + }; + + if blob.len() <= NONCE_LEN { + return Ok(None); + } + + let nonce = aes_gcm::Nonce::from_slice(&blob[..NONCE_LEN]); + let ciphertext = &blob[NONCE_LEN..]; + + let plaintext = match self.0.decrypt(nonce, ciphertext) { + Ok(p) => p, + Err(_) => return Ok(None), + }; + + let creds: TemporaryCredentials = serde_json::from_slice(&plaintext) + .map_err(|e| ProxyError::Internal(format!("unseal json: {e}")))?; + + if creds.expiration <= chrono::Utc::now() { + return Err(ProxyError::ExpiredCredentials); + } + + Ok(Some(creds)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::AccessScope; + + fn make_key() -> TokenKey { + let key_bytes = [0x42u8; 32]; + let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes); + TokenKey::from_base64(&encoded).unwrap() + } + + fn make_creds() -> TemporaryCredentials { + TemporaryCredentials { + access_key_id: "ASIATEMP".into(), + secret_access_key: "secret".into(), + session_token: "original-token".into(), + expiration: chrono::Utc::now() + chrono::Duration::hours(1), + allowed_scopes: vec![AccessScope { + bucket: "test-bucket".into(), + prefixes: vec![], + actions: vec![crate::types::Action::GetObject], + }], + assumed_role_id: "role-1".into(), + source_identity: "test".into(), + } + } + + #[test] + fn round_trip() { + let key = make_key(); + let creds = make_creds(); + let sealed = key.seal(&creds).unwrap(); + let unsealed = key.unseal(&sealed).unwrap().unwrap(); + assert_eq!(unsealed.access_key_id, creds.access_key_id); + assert_eq!(unsealed.secret_access_key, creds.secret_access_key); + assert_eq!(unsealed.assumed_role_id, creds.assumed_role_id); + } + + #[test] + fn wrong_key_returns_none() { + let key1 = make_key(); + let key2 = { + let key_bytes = [0x99u8; 32]; + let encoded = base64::engine::general_purpose::STANDARD.encode(key_bytes); + TokenKey::from_base64(&encoded).unwrap() + }; + let creds = make_creds(); + let sealed = key1.seal(&creds).unwrap(); + assert!(key2.unseal(&sealed).unwrap().is_none()); + } + + #[test] + fn non_sealed_token_returns_none() { + let key = make_key(); + assert!(key + .unseal("FwoGZXIvYXdzEBYaDGFiY2RlZjEyMzQ1Ng") + .unwrap() + .is_none()); + } + + #[test] + fn expired_token_returns_error() { + let key = make_key(); + let mut creds = make_creds(); + creds.expiration = chrono::Utc::now() - chrono::Duration::hours(1); + let sealed = key.seal(&creds).unwrap(); + let err = key.unseal(&sealed).unwrap_err(); + assert!(matches!(err, ProxyError::ExpiredCredentials)); + } + + #[test] + fn invalid_key_length_rejected() { + let short = base64::engine::general_purpose::STANDARD.encode([0u8; 16]); + assert!(TokenKey::from_base64(&short).is_err()); + } +} diff --git a/crates/libs/core/src/types.rs b/crates/libs/core/src/types.rs new file mode 100644 index 0000000..4f4fe74 --- /dev/null +++ b/crates/libs/core/src/types.rs @@ -0,0 +1,371 @@ +//! Shared types used across the proxy. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt; + +/// Configuration for a virtual bucket exposed by the proxy. +#[derive(Clone, Serialize, Deserialize)] +pub struct BucketConfig { + /// The virtual bucket name exposed to clients. + pub name: String, + + /// Provider type: "s3", "az", "gcs", etc. + pub backend_type: String, + + /// Optional prefix to prepend to all keys when forwarding. + pub backend_prefix: Option, + + /// Whether this bucket allows anonymous (unsigned) access. + pub anonymous_access: bool, + + /// IAM role ARNs that are allowed to access this bucket. + /// Empty means only anonymous access (if enabled) or long-lived credentials. + pub allowed_roles: Vec, + + /// Provider-specific config passed to the object_store builder. + /// Keys are the short aliases accepted by each provider's ConfigKey::from_str(). + /// S3: "endpoint", "bucket_name", "region", "access_key_id", "secret_access_key", "skip_signature" + /// Azure: "account_name", "container_name", "access_key", "skip_signature" + /// GCS: "bucket_name", "service_account_key", "skip_signature" + #[serde(default)] + pub backend_options: HashMap, +} + +/// Keys in `backend_options` that hold secret values. +const REDACTED_OPTION_KEYS: &[&str] = &[ + "secret_access_key", + "access_key", + "service_account_key", + "token", +]; + +impl fmt::Debug for BucketConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let redacted_opts: HashMap<&str, &str> = self + .backend_options + .iter() + .map(|(k, v)| { + let val = if REDACTED_OPTION_KEYS.contains(&k.as_str()) { + "[REDACTED]" + } else { + v.as_str() + }; + (k.as_str(), val) + }) + .collect(); + + f.debug_struct("BucketConfig") + .field("name", &self.name) + .field("backend_type", &self.backend_type) + .field("backend_prefix", &self.backend_prefix) + .field("anonymous_access", &self.anonymous_access) + .field("allowed_roles", &self.allowed_roles) + .field("backend_options", &redacted_opts) + .finish() + } +} + +/// Known backend provider types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BackendType { + S3, + Azure, + Gcs, +} + +impl BucketConfig { + /// Parse the `backend_type` string into a known [`BackendType`]. + pub fn parsed_backend_type(&self) -> Option { + match self.backend_type.as_str() { + "s3" => Some(BackendType::S3), + "az" | "azure" => Some(BackendType::Azure), + "gcs" | "gs" => Some(BackendType::Gcs), + _ => None, + } + } + + /// Whether this backend supports S3-style multipart uploads via raw HTTP. + pub fn supports_s3_multipart(&self) -> bool { + matches!(self.parsed_backend_type(), Some(BackendType::S3)) + } + + /// Look up a value in `backend_options`. + pub fn option(&self, key: &str) -> Option<&str> { + self.backend_options.get(key).map(|s| s.as_str()) + } +} + +/// Configuration for an IAM role that can be assumed via STS. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleConfig { + /// The role identifier (used as the RoleArn in AssumeRoleWithWebIdentity). + pub role_id: String, + + /// Human-readable name. + pub name: String, + + /// OIDC provider URLs trusted by this role (e.g., "https://token.actions.githubusercontent.com"). + pub trusted_oidc_issuers: Vec, + + /// Required audience claim value. + pub required_audience: Option, + + /// Conditions on the subject claim (glob patterns). + /// e.g., "repo:myorg/myrepo:ref:refs/heads/main" + pub subject_conditions: Vec, + + /// Buckets and prefixes this role can access. + pub allowed_scopes: Vec, + + /// Maximum session duration in seconds. + pub max_session_duration_secs: u64, +} + +/// Defines what a credential is allowed to access. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccessScope { + pub bucket: String, + /// Allowed key prefixes. Empty means full bucket access. + pub prefixes: Vec, + /// Allowed actions. + pub actions: Vec, +} + +/// S3 actions that can be authorized. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Action { + GetObject, + HeadObject, + PutObject, + ListBucket, + CreateMultipartUpload, + UploadPart, + CompleteMultipartUpload, + AbortMultipartUpload, + DeleteObject, +} + +/// A long-lived access credential stored in the config backend. +#[derive(Clone, Serialize, Deserialize)] +pub struct StoredCredential { + pub access_key_id: String, + /// This is the HMAC signing key, not stored in plaintext ideally. + pub secret_access_key: String, + pub principal_name: String, + pub allowed_scopes: Vec, + pub created_at: DateTime, + pub expires_at: Option>, + pub enabled: bool, +} + +impl fmt::Debug for StoredCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("StoredCredential") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"[REDACTED]") + .field("principal_name", &self.principal_name) + .field("allowed_scopes", &self.allowed_scopes) + .field("created_at", &self.created_at) + .field("expires_at", &self.expires_at) + .field("enabled", &self.enabled) + .finish() + } +} + +/// Temporary credentials minted by the STS API. +#[derive(Clone, Serialize, Deserialize)] +pub struct TemporaryCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expiration: DateTime, + pub allowed_scopes: Vec, + pub assumed_role_id: String, + pub source_identity: String, +} + +impl fmt::Debug for TemporaryCredentials { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TemporaryCredentials") + .field("access_key_id", &self.access_key_id) + .field("secret_access_key", &"[REDACTED]") + .field("session_token", &"[REDACTED]") + .field("expiration", &self.expiration) + .field("allowed_scopes", &self.allowed_scopes) + .field("assumed_role_id", &self.assumed_role_id) + .field("source_identity", &self.source_identity) + .finish() + } +} + +/// Represents the resolved identity after authentication. +#[derive(Debug, Clone)] +pub enum ResolvedIdentity { + Anonymous, + LongLived { credential: StoredCredential }, + Temporary { credentials: TemporaryCredentials }, +} + +/// The parsed S3 operation extracted from an incoming request. +#[derive(Debug, Clone)] +pub enum S3Operation { + GetObject { + bucket: String, + key: String, + }, + HeadObject { + bucket: String, + key: String, + }, + PutObject { + bucket: String, + key: String, + }, + CreateMultipartUpload { + bucket: String, + key: String, + }, + UploadPart { + bucket: String, + key: String, + upload_id: String, + part_number: u32, + }, + CompleteMultipartUpload { + bucket: String, + key: String, + upload_id: String, + }, + AbortMultipartUpload { + bucket: String, + key: String, + upload_id: String, + }, + DeleteObject { + bucket: String, + key: String, + }, + ListBucket { + bucket: String, + /// Raw query string from the incoming request, forwarded to the backend. + /// The proxy may modify `prefix` (prepend backend_prefix) and inject + /// defaults for `max-keys` and `list-type`. + raw_query: Option, + }, + /// List all virtual buckets exposed by the proxy. + ListBuckets, +} + +impl S3Operation { + /// The authorization action for this operation. + pub fn action(&self) -> Action { + match self { + S3Operation::GetObject { .. } => Action::GetObject, + S3Operation::HeadObject { .. } => Action::HeadObject, + S3Operation::PutObject { .. } => Action::PutObject, + S3Operation::ListBucket { .. } => Action::ListBucket, + S3Operation::CreateMultipartUpload { .. } => Action::CreateMultipartUpload, + S3Operation::UploadPart { .. } => Action::UploadPart, + S3Operation::CompleteMultipartUpload { .. } => Action::CompleteMultipartUpload, + S3Operation::AbortMultipartUpload { .. } => Action::AbortMultipartUpload, + S3Operation::DeleteObject { .. } => Action::DeleteObject, + S3Operation::ListBuckets => Action::ListBucket, + } + } + + /// The bucket name, if any. + pub fn bucket(&self) -> Option<&str> { + match self { + S3Operation::GetObject { bucket, .. } + | S3Operation::HeadObject { bucket, .. } + | S3Operation::PutObject { bucket, .. } + | S3Operation::ListBucket { bucket, .. } + | S3Operation::CreateMultipartUpload { bucket, .. } + | S3Operation::UploadPart { bucket, .. } + | S3Operation::CompleteMultipartUpload { bucket, .. } + | S3Operation::AbortMultipartUpload { bucket, .. } + | S3Operation::DeleteObject { bucket, .. } => Some(bucket), + S3Operation::ListBuckets => None, + } + } + + /// The object key, if any. Returns empty string for non-object operations. + pub fn key(&self) -> &str { + match self { + S3Operation::GetObject { key, .. } + | S3Operation::HeadObject { key, .. } + | S3Operation::PutObject { key, .. } + | S3Operation::CreateMultipartUpload { key, .. } + | S3Operation::UploadPart { key, .. } + | S3Operation::CompleteMultipartUpload { key, .. } + | S3Operation::AbortMultipartUpload { key, .. } + | S3Operation::DeleteObject { key, .. } => key, + S3Operation::ListBucket { .. } | S3Operation::ListBuckets => "", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_action() { + let op = S3Operation::GetObject { + bucket: "b".into(), + key: "k".into(), + }; + assert_eq!(op.action(), Action::GetObject); + + let op = S3Operation::PutObject { + bucket: "b".into(), + key: "k".into(), + }; + assert_eq!(op.action(), Action::PutObject); + + let op = S3Operation::ListBucket { + bucket: "b".into(), + raw_query: None, + }; + assert_eq!(op.action(), Action::ListBucket); + + assert_eq!(S3Operation::ListBuckets.action(), Action::ListBucket); + + let op = S3Operation::DeleteObject { + bucket: "b".into(), + key: "k".into(), + }; + assert_eq!(op.action(), Action::DeleteObject); + } + + #[test] + fn test_bucket() { + let op = S3Operation::GetObject { + bucket: "my-bucket".into(), + key: "k".into(), + }; + assert_eq!(op.bucket(), Some("my-bucket")); + + assert_eq!(S3Operation::ListBuckets.bucket(), None); + } + + #[test] + fn test_key() { + let op = S3Operation::GetObject { + bucket: "b".into(), + key: "my/key.txt".into(), + }; + assert_eq!(op.key(), "my/key.txt"); + + let op = S3Operation::ListBucket { + bucket: "b".into(), + raw_query: Some("prefix=foo/".into()), + }; + assert_eq!(op.key(), ""); + + assert_eq!(S3Operation::ListBuckets.key(), ""); + } +} diff --git a/crates/libs/oidc-provider/Cargo.toml b/crates/libs/oidc-provider/Cargo.toml new file mode 100644 index 0000000..017e435 --- /dev/null +++ b/crates/libs/oidc-provider/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "source-coop-oidc-provider" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "OIDC provider for outbound authentication — JWT signing, JWKS serving, and cloud credential exchange" + +[features] +default = [] +azure = [] +gcp = [] + +[dependencies] +source-coop-core.workspace = true +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +base64.workspace = true +rsa.workspace = true +sha2.workspace = true +tracing.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt", "macros"] } +rand = "0.8" diff --git a/crates/libs/oidc-provider/src/backend_auth.rs b/crates/libs/oidc-provider/src/backend_auth.rs new file mode 100644 index 0000000..6a747d3 --- /dev/null +++ b/crates/libs/oidc-provider/src/backend_auth.rs @@ -0,0 +1,250 @@ +//! OIDC-based backend credential resolution. +//! +//! When a bucket's `backend_options` contains `auth_type=oidc`, the proxy +//! mints a self-signed JWT and exchanges it for temporary cloud credentials +//! via the cloud provider's STS. The resolved credentials are injected back +//! into the config so the existing builder pipeline works unmodified. + +use source_coop_core::error::ProxyError; +use source_coop_core::oidc_backend::OidcBackendAuth; +use source_coop_core::types::BucketConfig; + +use crate::exchange::aws::AwsExchange; +use crate::{HttpExchange, OidcCredentialProvider}; + +/// AWS OIDC backend auth — exchanges a self-signed JWT for temporary +/// AWS credentials via `AssumeRoleWithWebIdentity`. +pub struct AwsOidcBackendAuth { + provider: OidcCredentialProvider, +} + +impl AwsOidcBackendAuth { + pub fn new(provider: OidcCredentialProvider) -> Self { + Self { provider } + } + + async fn resolve_aws(&self, config: &BucketConfig) -> Result { + let role_arn = config.option("oidc_role_arn").ok_or_else(|| { + ProxyError::ConfigError( + "auth_type=oidc requires 'oidc_role_arn' in backend_options".into(), + ) + })?; + let subject = config.option("oidc_subject").unwrap_or("s3-proxy"); + + let exchange = AwsExchange::new(role_arn.to_string()); + let creds = self + .provider + .get_credentials(role_arn, &exchange, subject, &[]) + .await?; + + let mut resolved = config.clone(); + resolved + .backend_options + .insert("access_key_id".into(), creds.access_key_id.clone()); + resolved + .backend_options + .insert("secret_access_key".into(), creds.secret_access_key.clone()); + resolved + .backend_options + .insert("token".into(), creds.session_token.clone()); + + // Remove OIDC-specific keys so they don't confuse the builder. + resolved.backend_options.remove("auth_type"); + resolved.backend_options.remove("oidc_role_arn"); + resolved.backend_options.remove("oidc_subject"); + + Ok(resolved) + } +} + +impl OidcBackendAuth for AwsOidcBackendAuth { + async fn resolve_credentials(&self, config: &BucketConfig) -> Result { + if config.option("auth_type") != Some("oidc") { + return Ok(config.clone()); + } + + // TODO: dispatch on backend_type for Azure/GCP when those exchanges are wired up. + match config.backend_type.as_str() { + "s3" => self.resolve_aws(config).await, + other => Err(ProxyError::ConfigError(format!( + "OIDC backend auth not yet supported for backend_type '{other}'" + ))), + } + } +} + +/// Wrapper enum that runtimes use as a single concrete `O` type. +/// +/// `Enabled` holds the live OIDC provider; `Disabled` is the no-op fallback. +/// When disabled and a bucket specifies `auth_type=oidc`, a `ConfigError` +/// is returned (same as `NoOidcAuth`). +pub enum MaybeOidcAuth { + Enabled(Box>), + Disabled, +} + +impl OidcBackendAuth for MaybeOidcAuth { + async fn resolve_credentials(&self, config: &BucketConfig) -> Result { + match self { + MaybeOidcAuth::Enabled(auth) => auth.resolve_credentials(config).await, + MaybeOidcAuth::Disabled => { + if config.option("auth_type") == Some("oidc") { + Err(ProxyError::ConfigError( + "bucket requires auth_type=oidc but no OIDC provider is configured".into(), + )) + } else { + Ok(config.clone()) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::jwt::JwtSigner; + use crate::OidcProviderError; + use chrono::{Duration, Utc}; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Clone)] + struct MockHttp { + call_count: Arc, + } + + impl MockHttp { + fn new() -> Self { + Self { + call_count: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl HttpExchange for MockHttp { + async fn post_form( + &self, + _url: &str, + _form: &[(&str, &str)], + ) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + let exp = (Utc::now() + Duration::hours(1)).to_rfc3339(); + Ok(format!( + r#" + + + AKID_OIDC + secret_oidc + token_oidc + {exp} + + + "# + )) + } + } + + fn test_signer() -> JwtSigner { + use rsa::pkcs8::EncodePrivateKey; + let mut rng = rand::rngs::OsRng; + let key = rsa::RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF).unwrap(); + JwtSigner::from_pem(&pem, "test-kid".into(), 300).unwrap() + } + + fn oidc_bucket_config() -> BucketConfig { + let mut opts = HashMap::new(); + opts.insert("auth_type".into(), "oidc".into()); + opts.insert("oidc_role_arn".into(), "arn:aws:iam::123:role/Test".into()); + opts.insert( + "endpoint".into(), + "https://s3.us-east-1.amazonaws.com".into(), + ); + opts.insert("bucket_name".into(), "my-bucket".into()); + opts.insert("region".into(), "us-east-1".into()); + BucketConfig { + name: "test".into(), + backend_type: "s3".into(), + backend_prefix: None, + anonymous_access: false, + allowed_roles: vec![], + backend_options: opts, + } + } + + fn static_bucket_config() -> BucketConfig { + let mut opts = HashMap::new(); + opts.insert("access_key_id".into(), "AKID_STATIC".into()); + opts.insert("secret_access_key".into(), "secret_static".into()); + opts.insert( + "endpoint".into(), + "https://s3.us-east-1.amazonaws.com".into(), + ); + opts.insert("bucket_name".into(), "my-bucket".into()); + BucketConfig { + name: "test".into(), + backend_type: "s3".into(), + backend_prefix: None, + anonymous_access: false, + allowed_roles: vec![], + backend_options: opts, + } + } + + #[tokio::test] + async fn resolve_injects_creds_for_oidc_bucket() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http, + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + let auth = AwsOidcBackendAuth::new(provider); + + let config = oidc_bucket_config(); + let resolved = auth.resolve_credentials(&config).await.unwrap(); + + assert_eq!(resolved.option("access_key_id"), Some("AKID_OIDC")); + assert_eq!(resolved.option("secret_access_key"), Some("secret_oidc")); + assert_eq!(resolved.option("token"), Some("token_oidc")); + assert!(resolved.option("auth_type").is_none()); + assert!(resolved.option("oidc_role_arn").is_none()); + } + + #[tokio::test] + async fn resolve_passes_through_static_bucket() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + let auth = AwsOidcBackendAuth::new(provider); + + let config = static_bucket_config(); + let resolved = auth.resolve_credentials(&config).await.unwrap(); + + assert_eq!(resolved.option("access_key_id"), Some("AKID_STATIC")); + assert_eq!(http.call_count.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn maybe_disabled_errors_on_oidc_bucket() { + let auth: MaybeOidcAuth = MaybeOidcAuth::Disabled; + let config = oidc_bucket_config(); + let err = auth.resolve_credentials(&config).await.unwrap_err(); + assert!(err.to_string().contains("no OIDC provider is configured")); + } + + #[tokio::test] + async fn maybe_disabled_passes_through_static_bucket() { + let auth: MaybeOidcAuth = MaybeOidcAuth::Disabled; + let config = static_bucket_config(); + let resolved = auth.resolve_credentials(&config).await.unwrap(); + assert_eq!(resolved.option("access_key_id"), Some("AKID_STATIC")); + } +} diff --git a/crates/libs/oidc-provider/src/cache.rs b/crates/libs/oidc-provider/src/cache.rs new file mode 100644 index 0000000..9e619d7 --- /dev/null +++ b/crates/libs/oidc-provider/src/cache.rs @@ -0,0 +1,95 @@ +//! TTL credential cache. +//! +//! Caches [`CloudCredentials`] by key, evicting entries that are within a +//! safety margin of expiration. This avoids redundant STS calls when the +//! same backend is accessed repeatedly within a short window. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use chrono::{Duration, Utc}; + +use crate::CloudCredentials; + +/// Safety margin before expiration — credentials are considered expired +/// this many seconds before their actual `expires_at`. +const EXPIRY_MARGIN_SECS: i64 = 60; + +/// Thread-safe TTL cache for cloud credentials. +pub struct CredentialCache { + entries: Mutex>>, +} + +impl Default for CredentialCache { + fn default() -> Self { + Self::new() + } +} + +impl CredentialCache { + pub fn new() -> Self { + Self { + entries: Mutex::new(HashMap::new()), + } + } + + /// Retrieve cached credentials if they are still valid. + pub fn get(&self, key: &str) -> Option> { + let entries = self.entries.lock().unwrap(); + if let Some(creds) = entries.get(key) { + let margin = Duration::seconds(EXPIRY_MARGIN_SECS); + if creds.expires_at > Utc::now() + margin { + return Some(creds.clone()); + } + } + None + } + + /// Store credentials in the cache. + pub fn put(&self, key: String, creds: Arc) { + let mut entries = self.entries.lock().unwrap(); + entries.insert(key, creds); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_creds(expires_in_secs: i64) -> CloudCredentials { + CloudCredentials { + access_key_id: "AKID".into(), + secret_access_key: "secret".into(), + session_token: "token".into(), + expires_at: Utc::now() + Duration::seconds(expires_in_secs), + } + } + + #[test] + fn cache_returns_valid_entry() { + let cache = CredentialCache::new(); + let creds = Arc::new(make_creds(600)); + cache.put("role-a".into(), creds.clone()); + + let got = cache.get("role-a"); + assert!(got.is_some()); + assert_eq!(got.unwrap().access_key_id, "AKID"); + } + + #[test] + fn cache_evicts_expired_entry() { + let cache = CredentialCache::new(); + // Expires in 30 seconds — within the 60-second margin + let creds = Arc::new(make_creds(30)); + cache.put("role-b".into(), creds); + + let got = cache.get("role-b"); + assert!(got.is_none()); + } + + #[test] + fn cache_miss_for_unknown_key() { + let cache = CredentialCache::new(); + assert!(cache.get("unknown").is_none()); + } +} diff --git a/crates/libs/oidc-provider/src/discovery.rs b/crates/libs/oidc-provider/src/discovery.rs new file mode 100644 index 0000000..d39ce95 --- /dev/null +++ b/crates/libs/oidc-provider/src/discovery.rs @@ -0,0 +1,41 @@ +//! OpenID Connect discovery document generation. + +/// Generate an OpenID Connect discovery document JSON. +/// +/// Cloud providers fetch this from `{issuer}/.well-known/openid-configuration` +/// to find the JWKS URI where they can retrieve the proxy's public key. +pub fn openid_configuration_json(issuer: &str, jwks_uri: &str) -> String { + let doc = serde_json::json!({ + "issuer": issuer, + "jwks_uri": jwks_uri, + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"], + }); + + doc.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn discovery_doc_has_required_fields() { + let json_str = openid_configuration_json( + "https://proxy.example.com", + "https://proxy.example.com/.well-known/jwks.json", + ); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + assert_eq!(parsed["issuer"], "https://proxy.example.com"); + assert_eq!( + parsed["jwks_uri"], + "https://proxy.example.com/.well-known/jwks.json" + ); + assert_eq!( + parsed["id_token_signing_alg_values_supported"], + serde_json::json!(["RS256"]) + ); + } +} diff --git a/crates/libs/oidc-provider/src/exchange/aws.rs b/crates/libs/oidc-provider/src/exchange/aws.rs new file mode 100644 index 0000000..7e73216 --- /dev/null +++ b/crates/libs/oidc-provider/src/exchange/aws.rs @@ -0,0 +1,140 @@ +//! AWS STS `AssumeRoleWithWebIdentity` credential exchange. + +use crate::{CloudCredentials, HttpExchange, OidcProviderError}; + +use super::CredentialExchange; + +/// Configuration for exchanging a JWT for AWS credentials. +#[derive(Debug, Clone)] +pub struct AwsExchange { + /// The ARN of the IAM role to assume (e.g. `arn:aws:iam::123456789012:role/MyRole`). + pub role_arn: String, + + /// AWS STS endpoint. Defaults to the global endpoint. + pub sts_endpoint: String, + + /// Session name included in the assumed role credentials. + pub session_name: String, +} + +impl Default for AwsExchange { + fn default() -> Self { + Self { + role_arn: String::new(), + sts_endpoint: "https://sts.amazonaws.com".into(), + session_name: "s3-proxy".into(), + } + } +} + +impl AwsExchange { + pub fn new(role_arn: String) -> Self { + Self { + role_arn, + ..Default::default() + } + } + + pub fn with_endpoint(mut self, endpoint: String) -> Self { + self.sts_endpoint = endpoint; + self + } + + pub fn with_session_name(mut self, name: String) -> Self { + self.session_name = name; + self + } +} + +impl CredentialExchange for AwsExchange { + async fn exchange(&self, http: &H, jwt: &str) -> Result { + let form = [ + ("Action", "AssumeRoleWithWebIdentity"), + ("Version", "2011-06-15"), + ("RoleArn", &self.role_arn), + ("RoleSessionName", &self.session_name), + ("WebIdentityToken", jwt), + ]; + + let body = http.post_form(&self.sts_endpoint, &form).await?; + + parse_assume_role_response(&body) + } +} + +/// Parse the XML response from AWS STS `AssumeRoleWithWebIdentity`. +fn parse_assume_role_response(xml: &str) -> Result { + // Extract fields from the STS XML response. + // The response structure is: + // + // + // + // ... + // ... + // ... + // ... + // + // + // + let access_key_id = extract_xml_value(xml, "AccessKeyId")?; + let secret_access_key = extract_xml_value(xml, "SecretAccessKey")?; + let session_token = extract_xml_value(xml, "SessionToken")?; + let expiration_str = extract_xml_value(xml, "Expiration")?; + + let expires_at = chrono::DateTime::parse_from_rfc3339(&expiration_str) + .map_err(|e| OidcProviderError::ExchangeError(format!("invalid Expiration: {e}")))? + .with_timezone(&chrono::Utc); + + Ok(CloudCredentials { + access_key_id, + secret_access_key, + session_token, + expires_at, + }) +} + +/// Simple XML tag value extraction (avoids pulling in a full XML parser). +fn extract_xml_value(xml: &str, tag: &str) -> Result { + let open = format!("<{tag}>"); + let close = format!(""); + let start = xml.find(&open).ok_or_else(|| { + OidcProviderError::ExchangeError(format!("missing <{tag}> in STS response")) + })? + open.len(); + let end = xml[start..].find(&close).ok_or_else(|| { + OidcProviderError::ExchangeError(format!("missing in STS response")) + })? + start; + Ok(xml[start..end].to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sts_response() { + let xml = r#" + + + + ASIATESTKEYID + testsecretkey + testsessiontoken + 2025-01-15T12:00:00Z + + +"#; + + let creds = parse_assume_role_response(xml).unwrap(); + assert_eq!(creds.access_key_id, "ASIATESTKEYID"); + assert_eq!(creds.secret_access_key, "testsecretkey"); + assert_eq!(creds.session_token, "testsessiontoken"); + assert_eq!(creds.expires_at.to_rfc3339(), "2025-01-15T12:00:00+00:00"); + } + + #[test] + fn parse_sts_response_missing_field() { + let xml = "AK"; + let err = parse_assume_role_response(xml).unwrap_err(); + assert!(err.to_string().contains("SecretAccessKey")); + } +} diff --git a/crates/libs/oidc-provider/src/exchange/azure.rs b/crates/libs/oidc-provider/src/exchange/azure.rs new file mode 100644 index 0000000..a45d2af --- /dev/null +++ b/crates/libs/oidc-provider/src/exchange/azure.rs @@ -0,0 +1,137 @@ +//! Azure AD federated token exchange. +//! +//! Exchanges a self-signed JWT for an Azure access token via the +//! OAuth 2.0 client credentials grant with federated identity. + +use crate::{CloudCredentials, HttpExchange, OidcProviderError}; + +use super::CredentialExchange; + +/// Configuration for exchanging a JWT for Azure credentials. +#[derive(Debug, Clone)] +pub struct AzureExchange { + /// Azure AD tenant ID. + pub tenant_id: String, + + /// Application (client) ID of the Azure AD app registration. + pub client_id: String, + + /// The scope to request (e.g. `https://storage.azure.com/.default`). + pub scope: String, +} + +impl AzureExchange { + pub fn new(tenant_id: String, client_id: String) -> Self { + Self { + tenant_id, + client_id, + scope: "https://storage.azure.com/.default".into(), + } + } + + pub fn with_scope(mut self, scope: String) -> Self { + self.scope = scope; + self + } + + fn token_endpoint(&self) -> String { + format!( + "https://login.microsoftonline.com/{}/oauth2/v2.0/token", + self.tenant_id + ) + } +} + +impl CredentialExchange for AzureExchange { + async fn exchange(&self, http: &H, jwt: &str) -> Result { + let form = [ + ("grant_type", "client_credentials"), + ( + "client_assertion_type", + "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + ), + ("client_assertion", jwt), + ("client_id", &self.client_id), + ("scope", &self.scope), + ]; + + let body = http.post_form(&self.token_endpoint(), &form).await?; + + parse_azure_token_response(&body) + } +} + +/// Parse an Azure AD token response. +fn parse_azure_token_response(json: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { + OidcProviderError::ExchangeError(format!("invalid Azure token response: {e}")) + })?; + + if let Some(err) = parsed.get("error") { + let desc = parsed + .get("error_description") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(OidcProviderError::ExchangeError(format!( + "Azure AD error: {err} — {desc}" + ))); + } + + let access_token = parsed["access_token"] + .as_str() + .ok_or_else(|| OidcProviderError::ExchangeError("missing access_token".into()))?; + + let expires_in = parsed["expires_in"] + .as_i64() + .ok_or_else(|| OidcProviderError::ExchangeError("missing expires_in".into()))?; + + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in); + + // Azure returns a bearer token, not key/secret pair. We store it as the + // session_token and use placeholder values for key_id/secret — the backend + // will use the bearer token directly. + Ok(CloudCredentials { + access_key_id: String::new(), + secret_access_key: String::new(), + session_token: access_token.to_string(), + expires_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_token_response() { + let json = r#"{ + "access_token": "eyJ0eXAiOiJKV1Q...", + "token_type": "Bearer", + "expires_in": 3600 + }"#; + + let creds = parse_azure_token_response(json).unwrap(); + assert_eq!(creds.session_token, "eyJ0eXAiOiJKV1Q..."); + assert!(creds.expires_at > chrono::Utc::now()); + } + + #[test] + fn parse_error_response() { + let json = r#"{ + "error": "invalid_client", + "error_description": "Client assertion failed" + }"#; + + let err = parse_azure_token_response(json).unwrap_err(); + assert!(err.to_string().contains("Azure AD error")); + } + + #[test] + fn token_endpoint_format() { + let ex = AzureExchange::new("tenant-123".into(), "client-456".into()); + assert_eq!( + ex.token_endpoint(), + "https://login.microsoftonline.com/tenant-123/oauth2/v2.0/token" + ); + } +} diff --git a/crates/libs/oidc-provider/src/exchange/gcp.rs b/crates/libs/oidc-provider/src/exchange/gcp.rs new file mode 100644 index 0000000..90a299c --- /dev/null +++ b/crates/libs/oidc-provider/src/exchange/gcp.rs @@ -0,0 +1,192 @@ +//! GCP credential exchange via STS + IAM `generateAccessToken`. +//! +//! The flow is: +//! 1. Exchange the self-signed JWT for a federated access token via GCP STS +//! 2. Use the federated token to call IAM `generateAccessToken` for a +//! service account, obtaining a GCP access token + +use crate::{CloudCredentials, HttpExchange, OidcProviderError}; + +use super::CredentialExchange; + +/// Configuration for exchanging a JWT for GCP credentials. +#[derive(Debug, Clone)] +pub struct GcpExchange { + /// The Workload Identity Pool provider resource name. + /// Format: `//iam.googleapis.com/projects/{project}/locations/global/workloadIdentityPools/{pool}/providers/{provider}` + pub provider_resource_name: String, + + /// The service account email to impersonate. + /// Format: `{name}@{project}.iam.gserviceaccount.com` + pub service_account_email: String, + + /// GCP STS endpoint. + pub sts_endpoint: String, + + /// Scopes to request for the impersonated service account. + pub scopes: Vec, +} + +impl GcpExchange { + pub fn new(provider_resource_name: String, service_account_email: String) -> Self { + Self { + provider_resource_name, + service_account_email, + sts_endpoint: "https://sts.googleapis.com/v1/token".into(), + scopes: vec!["https://www.googleapis.com/auth/cloud-platform".into()], + } + } + + fn generate_access_token_url(&self) -> String { + format!( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken", + self.service_account_email + ) + } +} + +impl CredentialExchange for GcpExchange { + async fn exchange(&self, http: &H, jwt: &str) -> Result { + // Step 1: Exchange JWT for federated access token via GCP STS + let sts_form = [ + ( + "grant_type", + "urn:ietf:params:oauth:grant-type:token-exchange", + ), + ("audience", &self.provider_resource_name), + ("scope", "https://www.googleapis.com/auth/cloud-platform"), + ( + "requested_token_type", + "urn:ietf:params:oauth:token-type:access_token", + ), + ("subject_token_type", "urn:ietf:params:oauth:token-type:jwt"), + ("subject_token", jwt), + ]; + + let sts_body = http.post_form(&self.sts_endpoint, &sts_form).await?; + + let federated_token = parse_sts_token_response(&sts_body)?; + + // Step 2: Impersonate service account to get a GCP access token + // This requires a JSON POST, but we encode it as form for simplicity + // with the HttpExchange trait. The IAM endpoint actually expects JSON, + // so we pass the scope as a form field that the caller's HttpExchange + // implementation should serialize as JSON if needed. + // + // For now, we use the federated token directly — the scope was already + // requested in step 1. Full impersonation can be added when needed. + // + // If the service account impersonation is required, the caller should + // handle the second step externally or we extend HttpExchange. + + let scopes_str = self.scopes.join(","); + let impersonation_form = [ + ("scope", scopes_str.as_str()), + ("_bearer_token", &federated_token), + ]; + + let iam_body = http + .post_form(&self.generate_access_token_url(), &impersonation_form) + .await?; + + parse_generate_access_token_response(&iam_body) + } +} + +/// Parse the GCP STS token exchange response. +fn parse_sts_token_response(json: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(json) + .map_err(|e| OidcProviderError::ExchangeError(format!("invalid GCP STS response: {e}")))?; + + if let Some(err) = parsed.get("error") { + let desc = parsed + .get("error_description") + .and_then(|v| v.as_str()) + .unwrap_or("unknown error"); + return Err(OidcProviderError::ExchangeError(format!( + "GCP STS error: {err} — {desc}" + ))); + } + + parsed["access_token"] + .as_str() + .map(|s| s.to_string()) + .ok_or_else(|| { + OidcProviderError::ExchangeError("missing access_token in STS response".into()) + }) +} + +/// Parse the IAM `generateAccessToken` response. +fn parse_generate_access_token_response(json: &str) -> Result { + let parsed: serde_json::Value = serde_json::from_str(json).map_err(|e| { + OidcProviderError::ExchangeError(format!("invalid generateAccessToken response: {e}")) + })?; + + let access_token = parsed["accessToken"] + .as_str() + .ok_or_else(|| OidcProviderError::ExchangeError("missing accessToken".into()))?; + + let expire_time = parsed["expireTime"] + .as_str() + .ok_or_else(|| OidcProviderError::ExchangeError("missing expireTime".into()))?; + + let expires_at = chrono::DateTime::parse_from_rfc3339(expire_time) + .map_err(|e| OidcProviderError::ExchangeError(format!("invalid expireTime: {e}")))? + .with_timezone(&chrono::Utc); + + // GCP returns a bearer token; same pattern as Azure. + Ok(CloudCredentials { + access_key_id: String::new(), + secret_access_key: String::new(), + session_token: access_token.to_string(), + expires_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_sts_token() { + let json = r#"{"access_token": "ya29.federated-token", "token_type": "Bearer", "expires_in": 3600}"#; + let token = parse_sts_token_response(json).unwrap(); + assert_eq!(token, "ya29.federated-token"); + } + + #[test] + fn parse_sts_error() { + let json = r#"{"error": "invalid_grant", "error_description": "bad token"}"#; + let err = parse_sts_token_response(json).unwrap_err(); + assert!(err.to_string().contains("GCP STS error")); + } + + #[test] + fn parse_generate_access_token() { + let json = r#"{ + "accessToken": "ya29.sa-access-token", + "expireTime": "2025-06-15T12:00:00Z" + }"#; + let creds = parse_generate_access_token_response(json).unwrap(); + assert_eq!(creds.session_token, "ya29.sa-access-token"); + assert_eq!(creds.expires_at.to_rfc3339(), "2025-06-15T12:00:00+00:00"); + } + + #[test] + fn parse_generate_access_token_missing_field() { + let json = r#"{"accessToken": "tok"}"#; + let err = parse_generate_access_token_response(json).unwrap_err(); + assert!(err.to_string().contains("expireTime")); + } + + #[test] + fn generate_access_token_url_format() { + let ex = GcpExchange::new( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/prov".into(), + "my-sa@my-project.iam.gserviceaccount.com".into(), + ); + assert!(ex + .generate_access_token_url() + .contains("my-sa@my-project.iam.gserviceaccount.com")); + } +} diff --git a/crates/libs/oidc-provider/src/exchange/mod.rs b/crates/libs/oidc-provider/src/exchange/mod.rs new file mode 100644 index 0000000..bff9d6a --- /dev/null +++ b/crates/libs/oidc-provider/src/exchange/mod.rs @@ -0,0 +1,26 @@ +//! Credential exchange — trade a self-signed JWT for cloud provider credentials. + +pub mod aws; +#[cfg(feature = "azure")] +pub mod azure; +#[cfg(feature = "gcp")] +pub mod gcp; + +use crate::{CloudCredentials, HttpExchange, OidcProviderError}; + +/// Trait for exchanging a self-signed JWT for cloud provider credentials. +/// +/// Each cloud provider has a different token exchange flow: +/// - AWS: `AssumeRoleWithWebIdentity` via STS +/// - Azure: Federated token exchange via Azure AD +/// - GCP: STS token exchange + `generateAccessToken` via IAM +pub trait CredentialExchange: + source_coop_core::maybe_send::MaybeSend + source_coop_core::maybe_send::MaybeSync +{ + fn exchange( + &self, + http: &H, + jwt: &str, + ) -> impl std::future::Future> + + source_coop_core::maybe_send::MaybeSend; +} diff --git a/crates/libs/oidc-provider/src/jwks.rs b/crates/libs/oidc-provider/src/jwks.rs new file mode 100644 index 0000000..2d6fc2f --- /dev/null +++ b/crates/libs/oidc-provider/src/jwks.rs @@ -0,0 +1,82 @@ +//! JWKS response generation — expose the proxy's public key as a JWK set. + +use base64::Engine; +use rsa::traits::PublicKeyParts; +use rsa::RsaPublicKey; + +/// Generate a JWKS JSON response containing the proxy's RSA public key. +/// +/// This is served at the JWKS URI referenced by the OIDC discovery document, +/// allowing relying parties (cloud providers) to verify JWTs signed by the proxy. +pub fn jwks_json(public_key: &RsaPublicKey, kid: &str) -> String { + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let n = public_key.n(); + let e = public_key.e(); + + let n_b64 = b64.encode(n.to_bytes_be()); + let e_b64 = b64.encode(e.to_bytes_be()); + + let jwks = serde_json::json!({ + "keys": [{ + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": kid, + "n": n_b64, + "e": e_b64, + }] + }); + + jwks.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use rsa::RsaPrivateKey; + + #[test] + fn jwks_contains_expected_fields() { + let mut rng = rand::rngs::OsRng; + let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let public_key: &RsaPublicKey = private_key.as_ref(); + + let json_str = jwks_json(public_key, "my-kid"); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let keys = parsed["keys"].as_array().unwrap(); + assert_eq!(keys.len(), 1); + + let key = &keys[0]; + assert_eq!(key["kty"], "RSA"); + assert_eq!(key["alg"], "RS256"); + assert_eq!(key["use"], "sig"); + assert_eq!(key["kid"], "my-kid"); + assert!(key["n"].as_str().unwrap().len() > 10); + assert!(key["e"].as_str().unwrap().len() > 0); + } + + #[test] + fn jwks_roundtrips_through_rsa() { + use rsa::BigUint; + + let mut rng = rand::rngs::OsRng; + let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let public_key: &RsaPublicKey = private_key.as_ref(); + + let json_str = jwks_json(public_key, "k1"); + let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + + let key = &parsed["keys"][0]; + let n_b64 = key["n"].as_str().unwrap(); + let e_b64 = key["e"].as_str().unwrap(); + + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; + let n = BigUint::from_bytes_be(&b64.decode(n_b64).unwrap()); + let e = BigUint::from_bytes_be(&b64.decode(e_b64).unwrap()); + + let reconstructed = RsaPublicKey::new(n, e).unwrap(); + assert_eq!(&reconstructed, public_key); + } +} diff --git a/crates/libs/oidc-provider/src/jwt.rs b/crates/libs/oidc-provider/src/jwt.rs new file mode 100644 index 0000000..2f435e5 --- /dev/null +++ b/crates/libs/oidc-provider/src/jwt.rs @@ -0,0 +1,183 @@ +//! JWT minting — sign JWTs with the proxy's RSA private key. + +use base64::Engine; +use chrono::{Duration, Utc}; +use rsa::pkcs1v15::SigningKey; +use rsa::pkcs8::DecodePrivateKey; +use rsa::signature::{SignatureEncoding, Signer}; +use rsa::RsaPrivateKey; +use sha2::Sha256; +use uuid::Uuid; + +use crate::OidcProviderError; + +/// Signs JWTs using an RSA private key (RS256). +#[derive(Clone)] +pub struct JwtSigner { + private_key: RsaPrivateKey, + kid: String, + ttl_seconds: i64, +} + +impl JwtSigner { + /// Create a signer from a PEM-encoded PKCS#8 private key. + pub fn from_pem(pem: &str, kid: String, ttl_seconds: i64) -> Result { + let private_key = RsaPrivateKey::from_pkcs8_pem(pem).map_err(|e| { + OidcProviderError::KeyError(format!("failed to parse private key: {e}")) + })?; + Ok(Self { + private_key, + kid, + ttl_seconds, + }) + } + + /// The key ID used in JWT headers and JWKS. + pub fn kid(&self) -> &str { + &self.kid + } + + /// Access the public key for JWKS generation. + pub fn public_key(&self) -> &rsa::RsaPublicKey { + self.private_key.as_ref() + } + + /// Sign a JWT with the given claims. + pub fn sign( + &self, + subject: &str, + issuer: &str, + audience: &str, + extra_claims: &[(&str, &str)], + ) -> Result { + let now = Utc::now(); + let exp = now + Duration::seconds(self.ttl_seconds); + let jti = Uuid::new_v4().to_string(); + + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; + + // Header + let header = serde_json::json!({ + "alg": "RS256", + "typ": "JWT", + "kid": self.kid, + }); + let header_b64 = b64.encode(header.to_string().as_bytes()); + + // Payload + let mut payload = serde_json::json!({ + "iss": issuer, + "sub": subject, + "aud": audience, + "exp": exp.timestamp(), + "iat": now.timestamp(), + "nbf": now.timestamp(), + "jti": jti, + }); + if let serde_json::Value::Object(ref mut map) = payload { + for (k, v) in extra_claims { + map.insert( + (*k).to_string(), + serde_json::Value::String((*v).to_string()), + ); + } + } + let payload_b64 = b64.encode(payload.to_string().as_bytes()); + + // Sign + let signing_input = format!("{header_b64}.{payload_b64}"); + let signing_key = SigningKey::::new(self.private_key.clone()); + let signature = signing_key.sign(signing_input.as_bytes()); + let sig_b64 = b64.encode(signature.to_bytes()); + + Ok(format!("{signing_input}.{sig_b64}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_key_pem() -> String { + // Generate a small RSA key for testing + use rsa::pkcs8::EncodePrivateKey; + let mut rng = rand::rngs::OsRng; + let key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF) + .unwrap() + .to_string() + } + + #[test] + fn sign_produces_three_part_jwt() { + let pem = test_key_pem(); + let signer = JwtSigner::from_pem(&pem, "test-kid".into(), 300).unwrap(); + let token = signer + .sign( + "my-subject", + "https://proxy.example.com", + "sts.amazonaws.com", + &[], + ) + .unwrap(); + + let parts: Vec<&str> = token.split('.').collect(); + assert_eq!(parts.len(), 3, "JWT should have header.payload.signature"); + + // Decode header and check kid + let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[0]) + .unwrap(); + let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap(); + assert_eq!(header["alg"], "RS256"); + assert_eq!(header["kid"], "test-kid"); + + // Decode payload and check standard claims + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert_eq!(payload["iss"], "https://proxy.example.com"); + assert_eq!(payload["sub"], "my-subject"); + assert_eq!(payload["aud"], "sts.amazonaws.com"); + assert!(payload["exp"].as_i64().unwrap() > payload["iat"].as_i64().unwrap()); + } + + #[test] + fn sign_includes_extra_claims() { + let pem = test_key_pem(); + let signer = JwtSigner::from_pem(&pem, "k1".into(), 60).unwrap(); + let token = signer + .sign("sub", "iss", "aud", &[("custom_key", "custom_value")]) + .unwrap(); + + let parts: Vec<&str> = token.split('.').collect(); + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[1]) + .unwrap(); + let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); + assert_eq!(payload["custom_key"], "custom_value"); + } + + #[test] + fn signature_is_verifiable() { + use rsa::pkcs1v15::VerifyingKey; + use rsa::signature::Verifier; + + let pem = test_key_pem(); + let signer = JwtSigner::from_pem(&pem, "k1".into(), 300).unwrap(); + let token = signer.sign("s", "i", "a", &[]).unwrap(); + + let parts: Vec<&str> = token.split('.').collect(); + let signing_input = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(parts[2]) + .unwrap(); + let signature = rsa::pkcs1v15::Signature::try_from(sig_bytes.as_slice()).unwrap(); + + let verifying_key = VerifyingKey::::new(signer.public_key().clone()); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .expect("signature should verify"); + } +} diff --git a/crates/libs/oidc-provider/src/lib.rs b/crates/libs/oidc-provider/src/lib.rs new file mode 100644 index 0000000..865d3b1 --- /dev/null +++ b/crates/libs/oidc-provider/src/lib.rs @@ -0,0 +1,301 @@ +//! OIDC provider for outbound authentication. +//! +//! This crate enables the proxy to act as its own OIDC identity provider: +//! +//! 1. **JWT signing** — mint JWTs signed with the proxy's RSA private key +//! 2. **JWKS serving** — expose the corresponding public key as a JWK set +//! 3. **OIDC discovery** — generate `.well-known/openid-configuration` responses +//! 4. **Credential exchange** — trade self-signed JWTs for cloud provider +//! credentials (AWS STS, Azure AD, GCP STS) +//! +//! The crate is runtime-agnostic: HTTP calls are abstracted behind an +//! [`HttpExchange`] trait so that each runtime (reqwest, Fetch API, etc.) +//! can provide its own implementation. + +pub mod backend_auth; +pub mod cache; +pub mod discovery; +pub mod exchange; +pub mod jwks; +pub mod jwt; + +use std::sync::Arc; + +use cache::CredentialCache; +use exchange::CredentialExchange; +use jwt::JwtSigner; + +/// Temporary cloud credentials obtained via token exchange. +#[derive(Debug, Clone)] +pub struct CloudCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expires_at: chrono::DateTime, +} + +/// HTTP client abstraction for outbound requests (STS token exchange). +/// +/// Each runtime provides its own implementation — `reqwest` on native, +/// `Fetch` on Cloudflare Workers. +pub trait HttpExchange: + Clone + source_coop_core::maybe_send::MaybeSend + source_coop_core::maybe_send::MaybeSync + 'static +{ + fn post_form( + &self, + url: &str, + form: &[(&str, &str)], + ) -> impl std::future::Future> + + source_coop_core::maybe_send::MaybeSend; +} + +/// Top-level provider that combines signing, exchange, and caching. +pub struct OidcCredentialProvider { + signer: JwtSigner, + cache: CredentialCache, + http: H, + issuer: String, + audience: String, +} + +impl OidcCredentialProvider { + pub fn new(signer: JwtSigner, http: H, issuer: String, audience: String) -> Self { + Self { + signer, + cache: CredentialCache::new(), + http, + issuer, + audience, + } + } + + /// Get credentials for a backend, using cached values when available. + /// + /// `exchange` describes how to trade the self-signed JWT for cloud + /// credentials (AWS, Azure, GCP). `cache_key` identifies the backend + /// for caching purposes (e.g. the role ARN). + pub async fn get_credentials>( + &self, + cache_key: &str, + exchange: &E, + subject: &str, + extra_claims: &[(&str, &str)], + ) -> Result, OidcProviderError> { + // Check cache first + if let Some(creds) = self.cache.get(cache_key) { + return Ok(creds); + } + + // Mint a JWT + let token = self + .signer + .sign(subject, &self.issuer, &self.audience, extra_claims)?; + + // Exchange it for cloud credentials + let creds: CloudCredentials = exchange.exchange(&self.http, &token).await?; + let creds = Arc::new(creds); + + // Cache + self.cache.put(cache_key.to_string(), creds.clone()); + + Ok(creds) + } + + /// Access the underlying signer (e.g. for JWKS generation). + pub fn signer(&self) -> &JwtSigner { + &self.signer + } +} + +/// Errors produced by this crate. +#[derive(Debug, thiserror::Error)] +pub enum OidcProviderError { + #[error("RSA key error: {0}")] + KeyError(String), + + #[error("JWT signing error: {0}")] + SigningError(String), + + #[error("credential exchange failed: {0}")] + ExchangeError(String), + + #[error("HTTP error: {0}")] + HttpError(String), +} + +impl From for source_coop_core::error::ProxyError { + fn from(e: OidcProviderError) -> Self { + source_coop_core::error::ProxyError::Internal(e.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Mock HTTP client that records calls and returns a preset AWS STS response. + #[derive(Clone)] + struct MockHttp { + call_count: Arc, + } + + impl MockHttp { + fn new() -> Self { + Self { + call_count: Arc::new(AtomicUsize::new(0)), + } + } + + fn calls(&self) -> usize { + self.call_count.load(Ordering::SeqCst) + } + } + + impl HttpExchange for MockHttp { + async fn post_form( + &self, + _url: &str, + _form: &[(&str, &str)], + ) -> Result { + self.call_count.fetch_add(1, Ordering::SeqCst); + let exp = (Utc::now() + Duration::hours(1)).to_rfc3339(); + Ok(format!( + r#" + + + AKID_MOCK + secret_mock + token_mock + {exp} + + + "# + )) + } + } + + fn test_signer() -> JwtSigner { + use rsa::pkcs8::EncodePrivateKey; + let mut rng = rand::rngs::OsRng; + let key = rsa::RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let pem = key.to_pkcs8_pem(rsa::pkcs8::LineEnding::LF).unwrap(); + JwtSigner::from_pem(&pem, "test-kid".into(), 300).unwrap() + } + + #[tokio::test] + async fn get_credentials_returns_fresh_on_first_call() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + + let exchange = exchange::aws::AwsExchange::new("arn:aws:iam::123:role/Test".into()); + let creds = provider + .get_credentials("role-a", &exchange, "my-sub", &[]) + .await + .unwrap(); + + assert_eq!(creds.access_key_id, "AKID_MOCK"); + assert_eq!(http.calls(), 1); + } + + #[tokio::test] + async fn get_credentials_uses_cache_on_second_call() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + + let exchange = exchange::aws::AwsExchange::new("arn:aws:iam::123:role/Test".into()); + + // First call — hits mock HTTP + let creds1 = provider + .get_credentials("role-a", &exchange, "sub", &[]) + .await + .unwrap(); + assert_eq!(http.calls(), 1); + + // Second call — should use cache, no additional HTTP call + let creds2 = provider + .get_credentials("role-a", &exchange, "sub", &[]) + .await + .unwrap(); + assert_eq!(http.calls(), 1); + assert_eq!(creds1.access_key_id, creds2.access_key_id); + } + + #[tokio::test] + async fn different_cache_keys_make_separate_calls() { + let http = MockHttp::new(); + let provider = OidcCredentialProvider::new( + test_signer(), + http.clone(), + "https://issuer.example.com".into(), + "sts.amazonaws.com".into(), + ); + + let exchange = exchange::aws::AwsExchange::new("arn:aws:iam::123:role/Test".into()); + + provider + .get_credentials("role-a", &exchange, "sub", &[]) + .await + .unwrap(); + provider + .get_credentials("role-b", &exchange, "sub", &[]) + .await + .unwrap(); + + assert_eq!(http.calls(), 2); + } + + #[test] + fn signed_jwt_is_verifiable_via_jwks_public_key() { + use base64::Engine; + use rsa::pkcs1v15::VerifyingKey; + use rsa::signature::Verifier; + use rsa::{BigUint, RsaPublicKey}; + + let signer = test_signer(); + + // Sign a JWT + let token = signer.sign("sub", "iss", "aud", &[]).unwrap(); + + // Generate JWKS from the same signer + let jwks_str = jwks::jwks_json(signer.public_key(), signer.kid()); + let jwks: serde_json::Value = serde_json::from_str(&jwks_str).unwrap(); + + // Extract public key from JWKS + let key = &jwks["keys"][0]; + let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD; + let n = BigUint::from_bytes_be(&b64.decode(key["n"].as_str().unwrap()).unwrap()); + let e = BigUint::from_bytes_be(&b64.decode(key["e"].as_str().unwrap()).unwrap()); + let reconstructed_key = RsaPublicKey::new(n, e).unwrap(); + + // Verify signature using the JWKS-derived key + let parts: Vec<&str> = token.split('.').collect(); + let signing_input = format!("{}.{}", parts[0], parts[1]); + let sig_bytes = b64.decode(parts[2]).unwrap(); + let signature = rsa::pkcs1v15::Signature::try_from(sig_bytes.as_slice()).unwrap(); + + let verifying_key = VerifyingKey::::new(reconstructed_key); + verifying_key + .verify(signing_input.as_bytes(), &signature) + .expect("JWT signed by JwtSigner should be verifiable via JWKS public key"); + } + + #[test] + fn error_converts_to_proxy_error() { + let err = OidcProviderError::ExchangeError("test".into()); + let proxy_err: source_coop_core::error::ProxyError = err.into(); + assert!(proxy_err.to_string().contains("test")); + assert_eq!(proxy_err.status_code(), 500); + } +} diff --git a/crates/libs/sts/Cargo.toml b/crates/libs/sts/Cargo.toml new file mode 100644 index 0000000..caa8958 --- /dev/null +++ b/crates/libs/sts/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "source-coop-sts" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "OIDC/STS authentication for the S3 proxy gateway" + +[dependencies] +source-coop-core.workspace = true +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +chrono.workspace = true +base64.workspace = true +rand.workspace = true +rsa.workspace = true +sha2.workspace = true +reqwest.workspace = true +tracing.workspace = true +quick-xml.workspace = true +url.workspace = true diff --git a/crates/libs/sts/README.md b/crates/libs/sts/README.md new file mode 100644 index 0000000..37a4039 --- /dev/null +++ b/crates/libs/sts/README.md @@ -0,0 +1,81 @@ +# source-coop-sts + +OIDC token exchange and STS credential minting for the S3 proxy gateway. Implements the `AssumeRoleWithWebIdentity` flow, allowing workloads like GitHub Actions to exchange OIDC JWTs for temporary, scoped S3 credentials. + +## What This Crate Does + +``` +GitHub Actions (or any OIDC provider) + │ + │ JWT (signed by provider) + ▼ +┌─────────────────────────────┐ +│ source-coop-sts │ +│ │ +│ 1. Decode JWT header │ +│ 2. Fetch JWKS from issuer │ +│ 3. Verify JWT signature │ +│ 4. Check trust policy: │ +│ - issuer ∈ trusted? │ +│ - audience matches? │ +│ - subject matches glob? │ +│ 5. Mint temporary creds │ +│ (AccessKeyId, │ +│ SecretAccessKey, │ +│ SessionToken) │ +│ 6. Store via ConfigProvider│ +└─────────────────────────────┘ + │ + │ TemporaryCredentials + ▼ +Client signs S3 requests with temp creds +``` + +## Runtime Coupling + +This crate uses `reqwest` for JWKS fetching, which works on both native and WASM targets (`reqwest` compiles to `wasm32-unknown-unknown` using `web-sys` fetch). It does not depend on Tokio directly; the async functions are runtime-agnostic. + +If you need to use a different HTTP client for JWKS fetching (e.g., the Workers Fetch API directly), you'd replace the `fetch_jwks` function in `jwks.rs` or introduce a trait for HTTP fetching. This is a reasonable follow-up if WASM binary size becomes a concern. + +## Module Overview + +``` +src/ +├── lib.rs Entry point: assume_role_with_web_identity(), subject glob matching +├── request.rs STS request parsing (AssumeRoleWithWebIdentity query params) +├── responses.rs STS XML response serialization +├── jwks.rs JWKS fetching, JWK parsing, JWT signature verification +└── sts.rs Temporary credential minting (AccessKeyId/SecretAccessKey/SessionToken) +``` + +## Usage + +Called by the proxy handler when it receives an STS `AssumeRoleWithWebIdentity` request: + +```rust +use source_coop_sts::assume_role_with_web_identity; +use source_coop_sts::request::{StsRequest, try_parse_sts_request}; + +// Parse from query string +let sts_request = try_parse_sts_request(Some(query)) + .transpose()? // Option> → Result> + .expect("STS request"); + +let creds = assume_role_with_web_identity( + &config_provider, + &sts_request, + "TEMPKEY", // key prefix for minted credentials +).await?; + +// creds.access_key_id, creds.secret_access_key, creds.session_token +// are returned to the client in an STS XML response. +``` + +## Trust Policies + +Roles define trust policies in the config: + +- **`trusted_oidc_issuers`** — which OIDC providers are accepted (e.g., `https://token.actions.githubusercontent.com`) +- **`required_audience`** — the `aud` claim the JWT must contain +- **`subject_conditions`** — glob patterns matched against the `sub` claim (e.g., `repo:myorg/myrepo:ref:refs/heads/main`, `repo:myorg/*`) +- **`allowed_scopes`** — buckets, prefixes, and actions the minted credentials grant access to diff --git a/crates/libs/sts/src/jwks.rs b/crates/libs/sts/src/jwks.rs new file mode 100644 index 0000000..84b70fe --- /dev/null +++ b/crates/libs/sts/src/jwks.rs @@ -0,0 +1,290 @@ +//! JWKS fetching and JWT verification. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use chrono::{DateTime, Utc}; + +use base64::Engine; +use rsa::pkcs1v15::VerifyingKey; +use rsa::signature::Verifier; +use rsa::{BigUint, RsaPublicKey}; +use serde::Deserialize; +use sha2::Sha256; +use source_coop_core::error::ProxyError; +use source_coop_core::types::RoleConfig; + +#[derive(Debug, Clone, Deserialize)] +pub struct JwksResponse { + pub keys: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JwkKey { + pub kid: String, + pub kty: String, + pub alg: Option, + pub n: Option, + pub e: Option, + #[serde(rename = "use")] + pub use_: Option, +} + +/// Fetch JWKS from an OIDC provider's well-known endpoint. +/// +/// Requires HTTPS issuer URLs per the OIDC specification. HTTP URLs are +/// rejected to prevent MITM attacks on JWKS discovery. +pub async fn fetch_jwks( + client: &reqwest::Client, + issuer: &str, +) -> Result { + let issuer = issuer.trim_end_matches('/'); + + if !issuer.starts_with("https://") { + return Err(ProxyError::ConfigError(format!( + "OIDC issuer must use HTTPS: {}", + issuer + ))); + } + + // First, try the .well-known/openid-configuration endpoint + let config_url = format!("{}/.well-known/openid-configuration", issuer); + + let config_resp = + client.get(&config_url).send().await.map_err(|e| { + ProxyError::InvalidOidcToken(format!("failed to fetch OIDC config: {}", e)) + })?; + + let config: serde_json::Value = config_resp + .json() + .await + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid OIDC config: {}", e)))?; + + let jwks_uri = config + .get("jwks_uri") + .and_then(|v| v.as_str()) + .ok_or_else(|| ProxyError::InvalidOidcToken("OIDC config missing jwks_uri".into()))?; + + // Fetch the JWKS + let jwks_resp = client + .get(jwks_uri) + .send() + .await + .map_err(|e| ProxyError::InvalidOidcToken(format!("failed to fetch JWKS: {}", e)))?; + + jwks_resp + .json() + .await + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid JWKS: {}", e))) +} + +/// Find a key in the JWKS by key ID. +pub fn find_key<'a>(jwks: &'a JwksResponse, kid: &str) -> Result<&'a JwkKey, ProxyError> { + jwks.keys + .iter() + .find(|k| k.kid == kid) + .ok_or_else(|| ProxyError::InvalidOidcToken(format!("key '{}' not found in JWKS", kid))) +} + +/// Decode a base64url-encoded string (no padding). +fn base64url_decode(input: &str) -> Result, ProxyError> { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(input) + .map_err(|e| ProxyError::InvalidOidcToken(format!("base64url decode error: {}", e))) +} + +/// Build an RSA public key from JWK `n` and `e` components. +fn rsa_public_key_from_components(n: &str, e: &str) -> Result { + let n_bytes = base64url_decode(n)?; + let e_bytes = base64url_decode(e)?; + let n_int = BigUint::from_bytes_be(&n_bytes); + let e_int = BigUint::from_bytes_be(&e_bytes); + RsaPublicKey::new(n_int, e_int) + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid RSA key: {}", e))) +} + +/// Verify a JWT using the provided JWK. +pub fn verify_token( + token: &str, + key: &JwkKey, + issuer: &str, + role: &RoleConfig, +) -> Result { + let n = key + .n + .as_ref() + .ok_or_else(|| ProxyError::InvalidOidcToken("JWK missing 'n' component".into()))?; + let e = key + .e + .as_ref() + .ok_or_else(|| ProxyError::InvalidOidcToken("JWK missing 'e' component".into()))?; + + // Split the JWT into parts + let parts: Vec<&str> = token.splitn(3, '.').collect(); + if parts.len() != 3 { + return Err(ProxyError::InvalidOidcToken("malformed JWT".into())); + } + let [header_b64, payload_b64, signature_b64] = [parts[0], parts[1], parts[2]]; + + // Verify the header specifies RS256 + let header_bytes = base64url_decode(header_b64)?; + let header: serde_json::Value = serde_json::from_slice(&header_bytes) + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid JWT header JSON: {}", e)))?; + let alg = header.get("alg").and_then(|v| v.as_str()).unwrap_or(""); + if alg != "RS256" { + return Err(ProxyError::InvalidOidcToken(format!( + "unsupported JWT algorithm: {}", + alg + ))); + } + + // Verify the RSA signature + let public_key = rsa_public_key_from_components(n, e)?; + let verifying_key = VerifyingKey::::new(public_key); + let signature_bytes = base64url_decode(signature_b64)?; + let signature = rsa::pkcs1v15::Signature::try_from(signature_bytes.as_slice()) + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid signature: {}", e)))?; + let signed_content = format!("{}.{}", header_b64, payload_b64); + verifying_key + .verify(signed_content.as_bytes(), &signature) + .map_err(|e| { + ProxyError::InvalidOidcToken(format!("JWT signature verification failed: {}", e)) + })?; + + // Decode and validate claims + let payload_bytes = base64url_decode(payload_b64)?; + let claims: serde_json::Value = serde_json::from_slice(&payload_bytes) + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid JWT payload JSON: {}", e)))?; + + // Validate issuer + let token_issuer = claims.get("iss").and_then(|v| v.as_str()).unwrap_or(""); + if token_issuer != issuer { + return Err(ProxyError::InvalidOidcToken(format!( + "issuer mismatch: expected {}, got {}", + issuer, token_issuer + ))); + } + + // Validate audience if required + if let Some(ref required_aud) = role.required_audience { + let aud_valid = match claims.get("aud") { + Some(serde_json::Value::String(aud)) => aud == required_aud, + Some(serde_json::Value::Array(auds)) => auds + .iter() + .any(|a| a.as_str() == Some(required_aud.as_str())), + _ => false, + }; + if !aud_valid { + return Err(ProxyError::InvalidOidcToken(format!( + "audience mismatch: expected {}", + required_aud + ))); + } + } + + // Validate time-based claims with clock skew tolerance + let now = chrono::Utc::now().timestamp(); + const CLOCK_SKEW_SECS: i64 = 60; + + if let Some(exp) = claims.get("exp").and_then(|v| v.as_i64()) { + if now > exp + CLOCK_SKEW_SECS { + return Err(ProxyError::InvalidOidcToken("token has expired".into())); + } + } + + if let Some(nbf) = claims.get("nbf").and_then(|v| v.as_i64()) { + if now < nbf - CLOCK_SKEW_SECS { + return Err(ProxyError::InvalidOidcToken( + "token is not yet valid".into(), + )); + } + } + + Ok(claims) +} + +/// In-memory cache for JWKS responses, keyed by issuer URL. +/// +/// OIDC providers publish JWKS keys that change infrequently. Caching avoids +/// a network round-trip to the provider on every token validation and prevents +/// DoS via repeated validation attempts. +/// +/// Failed fetches are cached with a shorter TTL (`failure_ttl`) to avoid +/// hammering broken endpoints while still retrying periodically. +/// +/// Uses `DateTime` instead of `std::time::Instant` for WASM compatibility +/// (`Instant` panics on `wasm32-unknown-unknown`). +pub struct JwksCache { + client: reqwest::Client, + ttl: Duration, + failure_ttl: Duration, + entries: Mutex, JwksResponse)>>, + failures: Mutex>>, +} + +impl JwksCache { + /// Create a new cache with the given TTL and HTTP client. + pub fn new(client: reqwest::Client, ttl: Duration) -> Self { + Self { + client, + ttl, + failure_ttl: Duration::from_secs(30), + entries: Mutex::new(HashMap::new()), + failures: Mutex::new(HashMap::new()), + } + } + + /// Fetch JWKS for the given issuer, returning a cached response if fresh. + pub async fn get_or_fetch(&self, issuer: &str) -> Result { + // Check cache + if let Some(cached) = self.get_cached(issuer) { + return Ok(cached); + } + + // Check if we recently failed for this issuer + { + let failures = self.failures.lock().unwrap(); + if let Some(failed_at) = failures.get(issuer) { + let elapsed = Utc::now().signed_duration_since(*failed_at).num_seconds(); + if elapsed >= 0 && (elapsed as u64) < self.failure_ttl.as_secs() { + return Err(ProxyError::InvalidOidcToken(format!( + "JWKS fetch for '{}' recently failed, retrying after backoff", + issuer + ))); + } + } + } + + // Cache miss — fetch from the network + match fetch_jwks(&self.client, issuer).await { + Ok(jwks) => { + let mut entries = self.entries.lock().unwrap(); + entries.insert(issuer.to_string(), (Utc::now(), jwks.clone())); + // Clear any failure state on success + drop(entries); + self.failures.lock().unwrap().remove(issuer); + Ok(jwks) + } + Err(e) => { + tracing::warn!(issuer = %issuer, error = %e, "JWKS fetch failed, backing off"); + self.failures + .lock() + .unwrap() + .insert(issuer.to_string(), Utc::now()); + Err(e) + } + } + } + + fn get_cached(&self, issuer: &str) -> Option { + let entries = self.entries.lock().unwrap(); + if let Some((fetched_at, jwks)) = entries.get(issuer) { + let elapsed = Utc::now().signed_duration_since(*fetched_at).num_seconds(); + if elapsed >= 0 && (elapsed as u64) < self.ttl.as_secs() { + return Some(jwks.clone()); + } + } + None + } +} diff --git a/crates/libs/sts/src/lib.rs b/crates/libs/sts/src/lib.rs new file mode 100644 index 0000000..e55c6fa --- /dev/null +++ b/crates/libs/sts/src/lib.rs @@ -0,0 +1,316 @@ +//! OIDC/STS authentication for the S3 proxy gateway. +//! +//! This crate implements the `AssumeRoleWithWebIdentity` STS API, allowing +//! workloads like GitHub Actions to exchange OIDC tokens for temporary S3 +//! credentials scoped to specific buckets and prefixes. +//! +//! # Flow +//! +//! 1. Client obtains a JWT from their OIDC provider (e.g., GitHub Actions ID token) +//! 2. Client calls `AssumeRoleWithWebIdentity` with the JWT and desired role +//! 3. This crate validates the JWT against the OIDC provider's JWKS +//! 4. Checks trust policy (issuer, audience, subject conditions) +//! 5. Mints temporary credentials (AccessKeyId/SecretAccessKey/SessionToken) +//! 6. Returns credentials to the client +//! +//! The client then uses these credentials to sign S3 requests normally. + +pub mod jwks; +pub mod request; +pub mod responses; +pub mod sts; + +use base64::Engine; +pub use jwks::JwksCache; +pub use request::try_parse_sts_request; +use request::StsRequest; +pub use responses::{build_sts_error_response, build_sts_response}; +use source_coop_core::config::ConfigProvider; +use source_coop_core::error::ProxyError; +use source_coop_core::sealed_token::TokenKey; +use source_coop_core::types::TemporaryCredentials; + +/// Try to handle an STS request. Returns `Some((status, xml))` if the query +/// contained an STS action, or `None` if it wasn't an STS request. +/// +/// Requires a `TokenKey` — minted credentials are encrypted into the session +/// token itself, so no server-side storage is needed. If `token_key` is `None` +/// and an STS request arrives, an error response is returned. +pub async fn try_handle_sts( + query: Option<&str>, + config: &C, + jwks_cache: &JwksCache, + token_key: Option<&TokenKey>, +) -> Option<(u16, String)> { + let sts_result = try_parse_sts_request(query)?; + let (status, xml) = match sts_result { + Ok(sts_request) => { + let Some(key) = token_key else { + tracing::error!("STS request received but SESSION_TOKEN_KEY is not configured"); + return Some(build_sts_error_response(&ProxyError::ConfigError( + "STS requires SESSION_TOKEN_KEY to be configured".into(), + ))); + }; + match assume_role_with_web_identity(config, &sts_request, "STSPRXY", jwks_cache, key) + .await + { + Ok(creds) => build_sts_response(&creds), + Err(e) => { + tracing::warn!(error = %e, "STS request failed"); + build_sts_error_response(&e) + } + } + } + Err(e) => build_sts_error_response(&e), + }; + Some((status, xml)) +} + +/// Decode JWT header and claims without signature verification. +fn jwt_decode_unverified( + token: &str, +) -> Result<(serde_json::Value, serde_json::Value), ProxyError> { + let mut parts = token.splitn(3, '.'); + let header_b64 = parts + .next() + .ok_or_else(|| ProxyError::InvalidOidcToken("malformed JWT".into()))?; + let payload_b64 = parts + .next() + .ok_or_else(|| ProxyError::InvalidOidcToken("malformed JWT".into()))?; + + let decode = |s: &str| -> Result { + let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(s) + .map_err(|e| ProxyError::InvalidOidcToken(format!("base64url decode error: {}", e)))?; + serde_json::from_slice(&bytes) + .map_err(|e| ProxyError::InvalidOidcToken(format!("invalid JWT JSON: {}", e))) + }; + + Ok((decode(header_b64)?, decode(payload_b64)?)) +} + +/// Validate an OIDC token and mint temporary credentials. +/// +/// Credentials are encrypted into a self-contained session token via `token_key`. +/// No server-side credential storage is needed. +pub async fn assume_role_with_web_identity( + config: &C, + sts_request: &StsRequest, + key_prefix: &str, + jwks_cache: &JwksCache, + token_key: &TokenKey, +) -> Result { + // Look up the role + let role = config + .get_role(&sts_request.role_arn) + .await? + .ok_or_else(|| ProxyError::RoleNotFound(sts_request.role_arn.to_string()))?; + + // Decode the JWT header and claims without verification to extract issuer and kid + let (header, insecure_claims) = jwt_decode_unverified(&sts_request.web_identity_token)?; + + let issuer = insecure_claims + .get("iss") + .and_then(|v| v.as_str()) + .ok_or_else(|| ProxyError::InvalidOidcToken("missing iss claim".into()))?; + + // Verify the issuer is trusted + if !role.trusted_oidc_issuers.iter().any(|i| i == issuer) { + return Err(ProxyError::InvalidOidcToken(format!( + "untrusted issuer: {}", + issuer + ))); + } + + // Fail fast on unsupported algorithms before making any network requests + let alg = header.get("alg").and_then(|v| v.as_str()).unwrap_or(""); + if alg != "RS256" { + return Err(ProxyError::InvalidOidcToken(format!( + "unsupported JWT algorithm: {}", + alg + ))); + } + + // Fetch JWKS (using cache) and verify the token + let jwks = jwks_cache.get_or_fetch(issuer).await?; + let kid = header + .get("kid") + .and_then(|v| v.as_str()) + .ok_or_else(|| ProxyError::InvalidOidcToken("JWT missing kid".into()))?; + + let key = jwks::find_key(&jwks, kid)?; + let claims = jwks::verify_token(&sts_request.web_identity_token, key, issuer, &role)?; + + // Check subject conditions + let subject = claims.get("sub").and_then(|v| v.as_str()).unwrap_or(""); + + if !role.subject_conditions.is_empty() { + let matches = role + .subject_conditions + .iter() + .any(|pattern| subject_matches(subject, pattern)); + if !matches { + return Err(ProxyError::InvalidOidcToken(format!( + "subject '{}' does not match any conditions", + subject + ))); + } + } + + // Mint temporary credentials (AWS enforces 900s minimum) + const MIN_SESSION_DURATION_SECS: u64 = 900; + let duration = sts_request + .duration_seconds + .unwrap_or(3600) + .clamp(MIN_SESSION_DURATION_SECS, role.max_session_duration_secs); + + let mut creds = sts::mint_temporary_credentials(&role, subject, duration, key_prefix, &claims); + + // Encrypt the full credentials into the session token — stateless, no storage needed + creds.session_token = token_key.seal(&creds)?; + + Ok(creds) +} + +/// Simple glob-style matching for subject conditions. +/// Supports `*` as a wildcard for any sequence of characters. +fn subject_matches(subject: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return subject == pattern; + } + + let mut remaining = subject; + + // First part must be a prefix + if !parts[0].is_empty() { + if !remaining.starts_with(parts[0]) { + return false; + } + remaining = &remaining[parts[0].len()..]; + } + + // Middle parts must appear in order + for part in &parts[1..parts.len() - 1] { + if part.is_empty() { + continue; + } + match remaining.find(part) { + Some(idx) => remaining = &remaining[idx + part.len()..], + None => return false, + } + } + + // Last part must be a suffix + let last = parts.last().unwrap(); + if !last.is_empty() { + return remaining.ends_with(last); + } + + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_subject_matching() { + // Trailing wildcard + assert!(subject_matches( + "repo:org/repo:ref:refs/heads/main", + "repo:org/repo:*" + )); + + // Match-all wildcard + assert!(subject_matches("repo:org/repo:ref:refs/heads/main", "*")); + + // Exact match (no wildcards) + assert!(subject_matches( + "repo:org/repo:ref:refs/heads/main", + "repo:org/repo:ref:refs/heads/main" + )); + + // Wrong prefix + assert!(!subject_matches( + "repo:org/repo:ref:refs/heads/main", + "repo:other/*" + )); + + // Multiple wildcards + assert!(subject_matches( + "repo:org/repo:ref:refs/heads/main", + "repo:org/*:ref:refs/heads/*" + )); + } + + #[test] + fn test_subject_matching_exact() { + assert!(subject_matches("abc", "abc")); + assert!(!subject_matches("abc", "abcd")); + assert!(!subject_matches("abcd", "abc")); + assert!(!subject_matches("", "abc")); + assert!(subject_matches("", "")); + } + + #[test] + fn test_subject_matching_leading_wildcard() { + assert!(subject_matches("anything", "*")); + assert!(subject_matches("", "*")); + assert!(subject_matches("foo", "*foo")); + assert!(subject_matches("xfoo", "*foo")); + assert!(!subject_matches("foox", "*foo")); + } + + #[test] + fn test_subject_matching_trailing_wildcard() { + assert!(subject_matches("foo", "foo*")); + assert!(subject_matches("foobar", "foo*")); + assert!(!subject_matches("xfoo", "foo*")); + } + + #[test] + fn test_subject_matching_middle_wildcard() { + assert!(subject_matches("foobar", "foo*bar")); + assert!(subject_matches("fooXbar", "foo*bar")); + assert!(subject_matches("fooXYZbar", "foo*bar")); + assert!(!subject_matches("fooXbaz", "foo*bar")); + assert!(!subject_matches("xfoobar", "foo*bar")); + } + + #[test] + fn test_subject_matching_multiple_wildcards() { + // Two wildcards with repeated literal + assert!(subject_matches("axbb", "a*b*b")); + assert!(!subject_matches("axb", "a*b*b")); + + // Wildcard must not overlap with suffix + assert!(!subject_matches("abc", "a*bc*c")); + assert!(subject_matches("abcc", "a*bc*c")); + + // Multiple wildcards requiring non-greedy left-to-right match + assert!(subject_matches("aab", "*a*ab")); + assert!(!subject_matches("xab", "*a*ab")); + + // Repeated pattern in subject + assert!(subject_matches("xababab", "*ab*ab")); + assert!(!subject_matches("xab", "*ab*ab")); + } + + #[test] + fn test_subject_matching_double_wildcard() { + assert!(subject_matches("anything", "**")); + assert!(subject_matches("", "**")); + } + + #[test] + fn test_subject_matching_empty_subject() { + assert!(subject_matches("", "*")); + assert!(!subject_matches("", "a")); + assert!(subject_matches("", "")); + } +} diff --git a/crates/libs/sts/src/request.rs b/crates/libs/sts/src/request.rs new file mode 100644 index 0000000..470478f --- /dev/null +++ b/crates/libs/sts/src/request.rs @@ -0,0 +1,101 @@ +//! STS request parsing. +//! +//! Extracts `AssumeRoleWithWebIdentity` parameters from query strings. + +use source_coop_core::error::ProxyError; + +/// Parsed STS `AssumeRoleWithWebIdentity` request parameters. +#[derive(Debug, Clone)] +pub struct StsRequest { + pub role_arn: String, + pub web_identity_token: String, + pub duration_seconds: Option, +} + +/// Try to parse an STS request from the query string. +/// +/// Returns `None` if the query does not contain `Action=AssumeRoleWithWebIdentity` +/// (i.e., this is not an STS request). Returns `Some(Ok(..))` on success or +/// `Some(Err(..))` if it is an STS request but required parameters are missing. +pub fn try_parse_sts_request(query: Option<&str>) -> Option> { + let q = query?; + let params: Vec<(String, String)> = url::form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + + let action = params.iter().find(|(k, _)| k == "Action"); + match action { + Some((_, value)) if value == "AssumeRoleWithWebIdentity" => {} + _ => return None, + } + + Some(parse_sts_params(¶ms)) +} + +fn parse_sts_params(params: &[(String, String)]) -> Result { + let role_arn = params + .iter() + .find(|(k, _)| k == "RoleArn") + .map(|(_, v)| v.clone()) + .ok_or_else(|| ProxyError::InvalidRequest("missing RoleArn".into()))?; + + let web_identity_token = params + .iter() + .find(|(k, _)| k == "WebIdentityToken") + .map(|(_, v)| v.clone()) + .ok_or_else(|| ProxyError::InvalidRequest("missing WebIdentityToken".into()))?; + + let duration_seconds = params + .iter() + .find(|(k, _)| k == "DurationSeconds") + .and_then(|(_, v)| v.parse().ok()); + + Ok(StsRequest { + role_arn, + web_identity_token, + duration_seconds, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_not_sts_request() { + assert!(try_parse_sts_request(None).is_none()); + assert!(try_parse_sts_request(Some("prefix=foo/")).is_none()); + assert!(try_parse_sts_request(Some("Action=ListBuckets")).is_none()); + } + + #[test] + fn test_valid_sts_request() { + let query = "Action=AssumeRoleWithWebIdentity&RoleArn=my-role&WebIdentityToken=tok123"; + let result = try_parse_sts_request(Some(query)).unwrap().unwrap(); + assert_eq!(result.role_arn, "my-role"); + assert_eq!(result.web_identity_token, "tok123"); + assert_eq!(result.duration_seconds, None); + } + + #[test] + fn test_sts_request_with_duration() { + let query = + "Action=AssumeRoleWithWebIdentity&RoleArn=r&WebIdentityToken=t&DurationSeconds=7200"; + let result = try_parse_sts_request(Some(query)).unwrap().unwrap(); + assert_eq!(result.duration_seconds, Some(7200)); + } + + #[test] + fn test_missing_role_arn() { + let query = "Action=AssumeRoleWithWebIdentity&WebIdentityToken=tok"; + let result = try_parse_sts_request(Some(query)).unwrap(); + assert!(result.is_err()); + } + + #[test] + fn test_missing_web_identity_token() { + let query = "Action=AssumeRoleWithWebIdentity&RoleArn=role"; + let result = try_parse_sts_request(Some(query)).unwrap(); + assert!(result.is_err()); + } +} diff --git a/crates/libs/sts/src/responses.rs b/crates/libs/sts/src/responses.rs new file mode 100644 index 0000000..e80c54a --- /dev/null +++ b/crates/libs/sts/src/responses.rs @@ -0,0 +1,97 @@ +//! STS XML response serialization. + +use quick_xml::se::to_string as xml_to_string; +use serde::Serialize; +use source_coop_core::error::ProxyError; +use source_coop_core::types::TemporaryCredentials; + +/// STS AssumeRoleWithWebIdentity response. +#[derive(Debug, Serialize)] +#[serde(rename = "AssumeRoleWithWebIdentityResponse")] +pub struct AssumeRoleWithWebIdentityResponse { + #[serde(rename = "AssumeRoleWithWebIdentityResult")] + pub result: AssumeRoleWithWebIdentityResult, +} + +#[derive(Debug, Serialize)] +pub struct AssumeRoleWithWebIdentityResult { + #[serde(rename = "Credentials")] + pub credentials: StsCredentials, + #[serde(rename = "AssumedRoleUser")] + pub assumed_role_user: AssumedRoleUser, +} + +#[derive(Debug, Serialize)] +pub struct StsCredentials { + #[serde(rename = "AccessKeyId")] + pub access_key_id: String, + #[serde(rename = "SecretAccessKey")] + pub secret_access_key: String, + #[serde(rename = "SessionToken")] + pub session_token: String, + #[serde(rename = "Expiration")] + pub expiration: String, +} + +#[derive(Debug, Serialize)] +pub struct AssumedRoleUser { + #[serde(rename = "AssumedRoleId")] + pub assumed_role_id: String, + #[serde(rename = "Arn")] + pub arn: String, +} + +impl AssumeRoleWithWebIdentityResponse { + pub fn to_xml(&self) -> String { + format!( + "\n{}", + xml_to_string(self).unwrap_or_default() + ) + } +} + +/// Build an STS success response (status code + XML body) from temporary credentials. +pub fn build_sts_response(creds: &TemporaryCredentials) -> (u16, String) { + let response = AssumeRoleWithWebIdentityResponse { + result: AssumeRoleWithWebIdentityResult { + credentials: StsCredentials { + access_key_id: creds.access_key_id.clone(), + secret_access_key: creds.secret_access_key.clone(), + session_token: creds.session_token.clone(), + expiration: creds.expiration.to_rfc3339(), + }, + assumed_role_user: AssumedRoleUser { + assumed_role_id: creds.assumed_role_id.clone(), + arn: creds.assumed_role_id.clone(), + }, + }, + }; + (200, response.to_xml()) +} + +/// Build an STS error response (status code + XML body) from a ProxyError. +pub fn build_sts_error_response(err: &ProxyError) -> (u16, String) { + let (status, code, message) = match err { + ProxyError::RoleNotFound(r) => ( + 400, + "MalformedPolicyDocument", + format!("role not found: {}", r), + ), + ProxyError::InvalidOidcToken(msg) => (400, "InvalidIdentityToken", msg.clone()), + ProxyError::InvalidRequest(msg) => (400, "InvalidParameterValue", msg.clone()), + ProxyError::AccessDenied => (403, "AccessDenied", "access denied".to_string()), + _ => (500, "InternalError", "internal error".to_string()), + }; + + let xml = format!( + "\n\ + \ + \ + {}\ + {}\ + \ + ", + code, message + ); + (status, xml) +} diff --git a/crates/libs/sts/src/sts.rs b/crates/libs/sts/src/sts.rs new file mode 100644 index 0000000..698bc23 --- /dev/null +++ b/crates/libs/sts/src/sts.rs @@ -0,0 +1,156 @@ +//! STS credential minting. + +use chrono::{Duration, Utc}; +use rand::RngCore; +use source_coop_core::types::{AccessScope, RoleConfig, TemporaryCredentials}; + +/// Resolve `{claim_name}` template variables in access scopes against JWT claims. +/// +/// Each `{name}` in `bucket` or `prefixes` is replaced with the corresponding +/// string claim value. Missing or non-string claims resolve to an empty string, +/// which will safely fail authorization downstream. +fn resolve_scopes(scopes: &[AccessScope], claims: &serde_json::Value) -> Vec { + scopes + .iter() + .map(|scope| { + let bucket = resolve_template(&scope.bucket, claims); + let prefixes = scope + .prefixes + .iter() + .map(|p| resolve_template(p, claims)) + .collect(); + AccessScope { + bucket, + prefixes, + actions: scope.actions.clone(), + } + }) + .collect() +} + +/// Replace all `{key}` placeholders in `template` with values from `claims`. +fn resolve_template(template: &str, claims: &serde_json::Value) -> String { + let mut result = template.to_string(); + // Find all {…} placeholders and replace them + while let Some(start) = result.find('{') { + if let Some(end) = result[start..].find('}') { + let end = start + end; + let key = &result[start + 1..end]; + let value = claims.get(key).and_then(|v| v.as_str()).unwrap_or(""); + result = format!("{}{}{}", &result[..start], value, &result[end + 1..]); + } else { + break; + } + } + result +} + +/// Mint a new set of temporary credentials for an assumed role. +/// +/// Template variables (`{claim_name}`) in `role.allowed_scopes` are resolved +/// against the provided JWT `claims` before being stored in the credentials. +pub fn mint_temporary_credentials( + role: &RoleConfig, + source_identity: &str, + duration_seconds: u64, + key_prefix: &str, + claims: &serde_json::Value, +) -> TemporaryCredentials { + let access_key_id = format!("{}{}", key_prefix, generate_random_id(16)); + let secret_access_key = generate_random_id(40); + let session_token = generate_session_token(); + + let expiration = Utc::now() + Duration::seconds(duration_seconds as i64); + + TemporaryCredentials { + access_key_id, + secret_access_key, + session_token, + expiration, + allowed_scopes: resolve_scopes(&role.allowed_scopes, claims), + assumed_role_id: role.role_id.clone(), + source_identity: source_identity.to_string(), + } +} + +fn generate_random_id(len: usize) -> String { + use base64::Engine; + let mut bytes = vec![0u8; len]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + // Take only alphanumeric chars to match AWS key format + encoded + .chars() + .filter(|c| c.is_alphanumeric()) + .take(len) + .collect() +} + +fn generate_session_token() -> String { + use base64::Engine; + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use source_coop_core::types::Action; + + fn scope(bucket: &str, prefixes: &[&str], actions: &[Action]) -> AccessScope { + AccessScope { + bucket: bucket.to_string(), + prefixes: prefixes.iter().map(|s| s.to_string()).collect(), + actions: actions.to_vec(), + } + } + + #[test] + fn resolve_template_in_bucket() { + let scopes = vec![scope("{sub}", &[], &[Action::GetObject])]; + let claims = json!({"sub": "alice"}); + let resolved = resolve_scopes(&scopes, &claims); + assert_eq!(resolved[0].bucket, "alice"); + } + + #[test] + fn resolve_template_in_prefix() { + let scopes = vec![scope("my-bucket", &["data/{sub}/"], &[Action::GetObject])]; + let claims = json!({"sub": "alice"}); + let resolved = resolve_scopes(&scopes, &claims); + assert_eq!(resolved[0].prefixes[0], "data/alice/"); + } + + #[test] + fn resolve_multiple_claims() { + let scopes = vec![scope("{org}", &["{sub}/"], &[Action::GetObject])]; + let claims = json!({"sub": "alice", "org": "acme"}); + let resolved = resolve_scopes(&scopes, &claims); + assert_eq!(resolved[0].bucket, "acme"); + assert_eq!(resolved[0].prefixes[0], "alice/"); + } + + #[test] + fn no_templates_unchanged() { + let scopes = vec![scope("static-bucket", &["prefix/"], &[Action::GetObject])]; + let claims = json!({"sub": "alice"}); + let resolved = resolve_scopes(&scopes, &claims); + assert_eq!(resolved[0].bucket, "static-bucket"); + assert_eq!(resolved[0].prefixes[0], "prefix/"); + } + + #[test] + fn missing_claim_resolves_to_empty() { + let scopes = vec![scope( + "{missing}", + &["{also_missing}/"], + &[Action::GetObject], + )]; + let claims = json!({"sub": "alice"}); + let resolved = resolve_scopes(&scopes, &claims); + assert_eq!(resolved[0].bucket, ""); + assert_eq!(resolved[0].prefixes[0], "/"); + } +} diff --git a/crates/runtimes/cf-workers/Cargo.toml b/crates/runtimes/cf-workers/Cargo.toml new file mode 100644 index 0000000..c7d1a2b --- /dev/null +++ b/crates/runtimes/cf-workers/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "source-coop-cf-workers" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Cloudflare Workers runtime for the S3 proxy gateway" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +source-coop-core = { workspace = true, features = ["axum"] } +source-coop-sts.workspace = true +source-coop-oidc-provider.workspace = true +source-coop-api.workspace = true +axum = { workspace = true, features = ["json"] } +bytes.workspace = true +http.workspace = true +serde.workspace = true +serde_json.workspace = true +tracing.workspace = true +thiserror.workspace = true +chrono.workspace = true +quick-xml.workspace = true +url.workspace = true +object_store = { workspace = true } +futures.workspace = true +http-body.workspace = true +http-body-util.workspace = true +async-trait.workspace = true +reqwest.workspace = true + +# Cloudflare Workers SDK +worker = { version = "0.7", features = ["http", "axum"] } +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +web-sys = { version = "0.3", features = [ + "Headers", + "ReadableStream", + "Request", + "RequestInit", + "Response", + "ResponseInit", +] } +console_error_panic_hook = "0.1.7" + +[dependencies.getrandom_v02] +package = "getrandom" +version = "0.2" +features = ["js"] + +[dependencies.getrandom_v03] +package = "getrandom" +version = "0.3" +features = ["wasm_js"] diff --git a/crates/runtimes/cf-workers/README.md b/crates/runtimes/cf-workers/README.md new file mode 100644 index 0000000..cde3653 --- /dev/null +++ b/crates/runtimes/cf-workers/README.md @@ -0,0 +1,149 @@ +# source-coop-cf-workers + +Cloudflare Workers runtime for the S3 proxy gateway. Deploys the proxy to the edge using Cloudflare's global network, using presigned URLs for zero-copy streaming and `object_store` with a custom `FetchConnector` for LIST operations. + +## How It Works + +``` +Client request + -> Worker fetch handler (lib.rs) + -> Convert worker::Request -> http types + -> Pick resolver: + - SOURCE_API_URL set? -> SourceCoopResolver (dynamic Source Cooperative backends) + - Otherwise -> DefaultResolver (static PROXY_CONFIG) + -> ProxyHandler::resolve_request() (from source-coop-core) + -> Forward: fetch(presigned URL) with ReadableStream passthrough (GET/HEAD/PUT/DELETE) + -> Response: LIST XML via object_store, errors, synthetic responses + -> NeedsBody: multipart operations via raw signed HTTP +``` + +`WorkerBackend` implements `ProxyBackend` with three capabilities: `create_signer()` generates presigned URLs for CRUD operations (executed via the Fetch API with JS `ReadableStream` passthrough — zero Rust stream involvement), `create_store()` uses a custom `FetchConnector` for LIST operations, and `send_raw()` handles multipart uploads. `FetchConnector` bridges `object_store` to the Workers Fetch API using `spawn_local` + channel patterns (since JS interop types are `!Send`). + +## Module Overview + +``` +src/ +├── lib.rs Worker entry point, two-phase request handling, Forward execution +├── body.rs ProxyResult → worker::Response conversion (Bytes/Empty only) +├── client.rs WorkerBackend implementing ProxyBackend, WorkerHttpClient, FetchHttpExchange +├── fetch_connector.rs FetchConnector/FetchService bridging object_store to Fetch API (LIST only) +└── tracing_layer.rs Minimal tracing subscriber for Workers console_log +``` + +## Operating Modes + +### Static Config Mode (default) + +Reads bucket configuration from the `PROXY_CONFIG` environment variable. Uses `DefaultResolver` which handles standard S3 path/virtual-host parsing, SigV4 authentication, and scope-based authorization. + +```toml +# wrangler.toml +[vars] +PROXY_CONFIG = '{"buckets":[...],"roles":[...],"credentials":[...]}' +VIRTUAL_HOST_DOMAIN = "s3.example.com" # optional, for virtual-hosted style +OIDC_PROVIDER_ISSUER = "https://data.example.com" # optional, for OIDC backend auth + +# Set via wrangler secret (PEM-encoded RSA private key): +# wrangler secret put OIDC_PROVIDER_KEY +``` + +### Source Cooperative Mode + +When `SOURCE_API_URL` is set, the worker uses `SourceCoopResolver` which resolves backends dynamically from the Source Cooperative API. This resolver implements a custom URL namespace: + +- `GET /` — synthetic empty ListBuckets +- `GET /{account_id}` — lists repositories via Source API, returns synthetic ListObjectsV2 with CommonPrefixes +- `GET /{account_id}?prefix=repo_id/subdir/` — proxies to the repo's backend with prefix rewriting +- `GET|PUT|... /{account_id}/{repo_id}/{key}` — proxies to the repo's S3 backend + +Authentication is handled by the Source API permissions endpoint rather than the core auth module. + +```toml +# wrangler.toml +[vars] +SOURCE_API_URL = "https://api.source.coop" +OIDC_PROVIDER_ISSUER = "https://data.source.coop" # optional, for OIDC backend auth + +# Set via wrangler secret: +# wrangler secret put SOURCE_API_KEY +# wrangler secret put OIDC_PROVIDER_KEY # optional, PEM-encoded RSA private key +``` + +### Implementing a Custom Resolver + +To add a new operating mode, implement `RequestResolver` in a new module: + +```rust +use source_coop_core::resolver::{RequestResolver, ResolvedAction, ListRewrite}; +use source_coop_core::error::ProxyError; + +#[derive(Clone)] +struct MyResolver { /* ... */ } + +impl RequestResolver for MyResolver { + async fn resolve( + &self, + method: &http::Method, + path: &str, + query: Option<&str>, + headers: &http::HeaderMap, + ) -> Result { + // Parse the URL, authenticate, resolve a BucketConfig, + // and return ResolvedAction::Proxy or ResolvedAction::Response. + todo!() + } +} +``` + +Then add a branch in `lib.rs`: + +```rust +if let Ok(my_config) = env.var("MY_MODE") { + let resolver = MyResolver::new(/* ... */); + let handler = ProxyHandler::new(client::WorkerBackend, resolver); + return handle_action(&req, method, &handler, &path, query.as_deref(), &headers).await; +} +``` + +## Local Development + +Run MinIO via Docker Compose from the repo root, then start the worker with Wrangler: + +```bash +# Terminal 1: start MinIO (from repo root) +docker compose up + +# Terminal 2: start the worker dev server +cd crates/runtimes/cf-workers +npx wrangler dev +``` + +Wrangler starts a local server (default `:8787`). The `wrangler.toml` includes a `PROXY_CONFIG` var pointing at `localhost:9000` (MinIO). + +```bash +# Test it +curl http://localhost:8787/public-data/hello.txt +``` + +Note: `wrangler dev` runs the WASM module in a local Workerd runtime. Outbound `fetch()` calls from the worker to `localhost:9000` work because Wrangler's dev server runs on the host network. + +## Deployment + +```bash +cd crates/runtimes/cf-workers + +# Build and deploy to Cloudflare +npx wrangler deploy +``` + +For production, update the `PROXY_CONFIG` var in `wrangler.toml` (or set it via the Cloudflare dashboard / `wrangler secret`) to point at your real backend endpoints. + +## Why a Separate Crate + +Cloudflare Workers compile to `wasm32-unknown-unknown` and link against `worker-rs`, `wasm-bindgen`, and `web-sys`. These dependencies are incompatible with native targets. Keeping them isolated means `cargo build` for the server crate doesn't pull in WASM tooling, and `wrangler build` for this crate doesn't pull in Tokio. + +This crate must always be built with `--target wasm32-unknown-unknown`: + +```bash +cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown +``` diff --git a/crates/runtimes/cf-workers/node_modules/.cache/wrangler/wrangler-account.json b/crates/runtimes/cf-workers/node_modules/.cache/wrangler/wrangler-account.json new file mode 100644 index 0000000..f879448 --- /dev/null +++ b/crates/runtimes/cf-workers/node_modules/.cache/wrangler/wrangler-account.json @@ -0,0 +1,6 @@ +{ + "account": { + "id": "83a892ddc9739cbda441192079db2a3d", + "name": "alukach" + } +} \ No newline at end of file diff --git a/crates/runtimes/cf-workers/node_modules/.mf/cf.json b/crates/runtimes/cf-workers/node_modules/.mf/cf.json new file mode 100644 index 0000000..a5b75fb --- /dev/null +++ b/crates/runtimes/cf-workers/node_modules/.mf/cf.json @@ -0,0 +1 @@ +{"httpProtocol":"HTTP/1.1","clientAcceptEncoding":"gzip, deflate, br","requestPriority":"","edgeRequestKeepAliveStatus":1,"requestHeaderNames":{},"clientTcpRtt":213,"colo":"YYZ","asn":852,"asOrganization":"TELUS Communications Inc.","country":"CA","isEUCountry":false,"city":"Toronto","continent":"NA","region":"Ontario","regionCode":"ON","timezone":"America/Toronto","longitude":"-79.39864","latitude":"43.70643","postalCode":"M5A","tlsVersion":"TLSv1.3","tlsCipher":"AEAD-AES256-GCM-SHA384","tlsClientRandom":"BlauG9Yq3y4A4iUSUbDmmqu1JGI5+OLuye+ersJL+gc=","tlsClientCiphersSha1":"JZtiTn8H/ntxORk+XXvU2EvNoz8=","tlsClientExtensionsSha1":"Y7DIC8A6G0/aXviZ8ie/xDbJb7g=","tlsClientExtensionsSha1Le":"6e+q3vPm88rSgMTN/h7WTTxQ2wQ=","tlsExportedAuthenticator":{"clientHandshake":"3064b89d755921a2a20c9672b8aef197269b146c504f5d63b8567a56ccf74c0a1303617b2a56d4011208c545cc2afa27","serverHandshake":"eeb8136f336b11f22f286b20527040da29ffbd5e4fdf4f69ceb6c496734c9b1f1c8ec4fe395b4cc47c6512f6d48018b7","clientFinished":"ea40955f5323ec638c6a7cd82dd4bb3aee3e18c32612b802604e79eb9614f58041235484cd134fa697d1e6808437d17f","serverFinished":"1fff715305f8ef3fdd5dd95e84530dc2af4f86bb85d7236e192cfa0bf3f6861d56bba039114e7c9d4329fdf1ddd2d6c8"},"tlsClientHelloLength":"386","tlsClientAuth":{"certPresented":"0","certVerified":"NONE","certRevoked":"0","certIssuerDN":"","certSubjectDN":"","certIssuerDNRFC2253":"","certSubjectDNRFC2253":"","certIssuerDNLegacy":"","certSubjectDNLegacy":"","certSerial":"","certIssuerSerial":"","certSKI":"","certIssuerSKI":"","certFingerprintSHA1":"","certFingerprintSHA256":"","certNotBefore":"","certNotAfter":""},"verifiedBotCategory":"","botManagement":{"corporateProxy":false,"verifiedBot":false,"jsDetection":{"passed":false},"staticResource":false,"detectionIds":{},"score":99}} \ No newline at end of file diff --git a/crates/runtimes/cf-workers/src/client.rs b/crates/runtimes/cf-workers/src/client.rs new file mode 100644 index 0000000..97fe026 --- /dev/null +++ b/crates/runtimes/cf-workers/src/client.rs @@ -0,0 +1,257 @@ +//! Backend client and HTTP helpers for the Cloudflare Workers runtime. +//! +//! Contains: +//! - `WorkerBackend` — implements `ProxyBackend` using the Fetch API + FetchConnector +//! - `WorkerHttpClient` — implements `HttpClient` for server-to-server API calls + +use crate::fetch_connector::FetchConnector; +use bytes::Bytes; +use http::HeaderMap; +use object_store::list::PaginatedListStore; +use object_store::signer::Signer; +use serde::de::DeserializeOwned; +use source_coop_api::api::{CacheOptions, HttpClient}; +use source_coop_core::backend::{ + build_paginated_list_store, build_signer, ProxyBackend, RawResponse, StoreBuilder, +}; +use source_coop_core::error::ProxyError; +use source_coop_core::types::BucketConfig; +use source_coop_oidc_provider::{HttpExchange, OidcProviderError}; +use std::sync::Arc; +use worker::{Cache, Fetch}; + +/// Build the cache key URL for the Cache API. +/// +/// When a custom key is provided, it is formatted as `https://cache.internal/{key}` +/// because the Cache API requires valid URLs as keys. +fn cache_key_url(url: &str, opts: &CacheOptions) -> String { + match &opts.cache_key { + Some(key) => format!("https://cache.internal/{}", key), + None => url.to_string(), + } +} + +/// HTTP client for the Cloudflare Workers runtime. +/// +/// Uses the Workers Fetch API for requests and the Cache API for caching. +#[derive(Clone)] +pub struct WorkerHttpClient; + +impl HttpClient for WorkerHttpClient { + async fn fetch_json( + &self, + url: &str, + headers: &[(&str, &str)], + cache: Option<&CacheOptions>, + ) -> Result, ProxyError> { + // Check cache for a hit before making the request. + let cache_state = if let Some(opts) = cache { + let key = cache_key_url(url, opts); + let cf_cache = Cache::default(); + match cf_cache.get(&key, false).await { + Ok(Some(mut cached)) => { + if let Ok(text) = cached.text().await { + if let Ok(value) = serde_json::from_str(&text) { + return Ok(Some(value)); + } + } + // Cache hit but couldn't deserialize — fall through to fetch. + Some((cf_cache, key)) + } + _ => Some((cf_cache, key)), + } + } else { + None + }; + + // Build and execute the fetch request. + let mut req_init = worker::RequestInit::new(); + let worker_headers = worker::Headers::new(); + for (k, v) in headers { + worker_headers + .set(k, v) + .map_err(|e| ProxyError::Internal(format!("failed to set header: {}", e)))?; + } + req_init.with_headers(worker_headers); + + let req = worker::Request::new_with_init(url, &req_init) + .map_err(|e| ProxyError::Internal(format!("failed to create request: {}", e)))?; + + let mut resp = Fetch::Request(req) + .send() + .await + .map_err(|e| ProxyError::BackendError(format!("fetch failed: {}", e)))?; + + let status = resp.status_code(); + + let text = resp + .text() + .await + .map_err(|e| ProxyError::Internal(format!("failed to read text: {}", e)))?; + + if status == 404 { + return Ok(None); + } + + if !(200..300).contains(&status) { + return Err(ProxyError::BackendError(format!( + "API request to {} returned status {}", + url, status + ))); + } + + // Cache successful responses via the Cache API. + if let Some((cf_cache, key)) = cache_state { + let ttl = cache.unwrap().cache_ttl; + if let Ok(mut response) = worker::Response::ok(&text) { + let _ = response + .headers_mut() + .set("Cache-Control", &format!("max-age={}", ttl)); + // cache.put is fire-and-forget; ignore errors. + let _ = cf_cache.put(&key, response).await; + } + } + + serde_json::from_str(&text) + .map(Some) + .map_err(|e| ProxyError::Internal(format!("failed to deserialize response: {}", e))) + } +} + +/// Backend for the Cloudflare Workers runtime. +/// +/// Uses `FetchConnector` for `object_store` HTTP requests and `web_sys::fetch` +/// for raw multipart operations. +#[derive(Clone)] +pub struct WorkerBackend; + +impl ProxyBackend for WorkerBackend { + fn create_paginated_store( + &self, + config: &BucketConfig, + ) -> Result, ProxyError> { + build_paginated_list_store(config, |b| match b { + StoreBuilder::S3(s) => StoreBuilder::S3(s.with_http_connector(FetchConnector)), + }) + } + + fn create_signer(&self, config: &BucketConfig) -> Result, ProxyError> { + build_signer(config) + } + + async fn send_raw( + &self, + method: http::Method, + url: String, + headers: HeaderMap, + body: Bytes, + ) -> Result { + tracing::debug!( + method = %method, + url = %url, + "worker: sending raw backend request via Fetch API" + ); + + // Build web_sys::Headers + let ws_headers = web_sys::Headers::new() + .map_err(|e| ProxyError::Internal(format!("failed to create Headers: {:?}", e)))?; + + for (key, value) in headers.iter() { + if let Ok(v) = value.to_str() { + let _ = ws_headers.set(key.as_str(), v); + } + } + + // Build web_sys::RequestInit + let init = web_sys::RequestInit::new(); + init.set_method(method.as_str()); + init.set_headers(&ws_headers.into()); + + // Set body for methods that carry one + if !body.is_empty() { + let uint8 = js_sys::Uint8Array::from(body.as_ref()); + init.set_body(&uint8.into()); + } + + let ws_request = web_sys::Request::new_with_str_and_init(&url, &init) + .map_err(|e| ProxyError::BackendError(format!("failed to create request: {:?}", e)))?; + + // Fetch via worker + let worker_req: worker::Request = ws_request.into(); + let mut worker_resp = Fetch::Request(worker_req) + .send() + .await + .map_err(|e| ProxyError::BackendError(format!("fetch failed: {}", e)))?; + + let status = worker_resp.status_code(); + + // Read response body as bytes (multipart responses are small) + let resp_bytes = worker_resp + .bytes() + .await + .map_err(|e| ProxyError::Internal(format!("failed to read response: {}", e)))?; + + // Convert response headers + let ws_response: web_sys::Response = worker_resp.into(); + let resp_headers = extract_response_headers(&ws_response.headers()); + + Ok(RawResponse { + status, + headers: resp_headers, + body: Bytes::from(resp_bytes), + }) + } +} + +/// Headers to extract from backend responses. +pub const RESPONSE_HEADER_ALLOWLIST: &[&str] = &[ + "content-type", + "content-length", + "content-range", + "etag", + "last-modified", + "accept-ranges", + "content-encoding", + "content-disposition", + "cache-control", + "x-amz-request-id", + "x-amz-version-id", + "location", +]; + +/// Extract response headers from a `web_sys::Headers` using an allowlist. +pub fn extract_response_headers(ws_headers: &web_sys::Headers) -> HeaderMap { + let mut resp_headers = HeaderMap::new(); + for name in RESPONSE_HEADER_ALLOWLIST { + if let Ok(Some(value)) = ws_headers.get(name) { + if let Ok(parsed) = value.parse() { + resp_headers.insert(*name, parsed); + } + } + } + resp_headers +} + +/// [`HttpExchange`] implementation using reqwest on WASM (wraps `web_sys::fetch`). +#[derive(Clone)] +pub struct FetchHttpExchange; + +impl HttpExchange for FetchHttpExchange { + async fn post_form( + &self, + url: &str, + form: &[(&str, &str)], + ) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(url) + .form(form) + .send() + .await + .map_err(|e| OidcProviderError::HttpError(e.to_string()))?; + + resp.text() + .await + .map_err(|e| OidcProviderError::HttpError(e.to_string())) + } +} diff --git a/crates/runtimes/cf-workers/src/fetch_connector.rs b/crates/runtimes/cf-workers/src/fetch_connector.rs new file mode 100644 index 0000000..aea686b --- /dev/null +++ b/crates/runtimes/cf-workers/src/fetch_connector.rs @@ -0,0 +1,157 @@ +//! Custom `HttpConnector` for `object_store` on Cloudflare Workers. +//! +//! Uses the Workers Fetch API to make HTTP requests, bridging the `!Send` +//! JS interop boundary via channels. +//! +//! Adapted from WITHOUT the stream +//! stashing hack — bytes flow through normally via mpsc channels. + +use bytes::Bytes; +use futures::channel::{mpsc, oneshot}; +use futures::{SinkExt, StreamExt}; +use http_body::Frame; +use http_body_util::StreamBody; +use object_store::client::{ + HttpClient, HttpConnector, HttpError, HttpErrorKind, HttpRequest, HttpResponse, + HttpResponseBody, HttpService, +}; +use object_store::ClientOptions; +use wasm_bindgen_futures::spawn_local; + +/// A factory for creating HTTP clients that use the Workers Fetch API. +#[derive(Debug, Default, Clone)] +pub struct FetchConnector; + +impl HttpConnector for FetchConnector { + fn connect(&self, _options: &ClientOptions) -> object_store::Result { + Ok(HttpClient::new(FetchService)) + } +} + +/// HTTP service implementation using the Workers Fetch API. +/// +/// Each `call()` spawns a `spawn_local` task because `worker::Fetch::send()` +/// returns a `!Send` future. A oneshot channel bridges the result back to +/// the `Send` context that `object_store` expects. +#[derive(Debug, Clone)] +struct FetchService; + +impl FetchService { + async fn do_fetch(&self, worker_req: worker::Request) -> Result { + let (tx, rx) = oneshot::channel(); + + spawn_local(async move { + let result = Self::fetch_inner(worker_req).await; + let _ = tx.send(result); + }); + + rx.await.unwrap_or_else(|_| { + Err(HttpError::new( + HttpErrorKind::Unknown, + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "fetch channel dropped"), + )) + }) + } + + async fn fetch_inner(worker_req: worker::Request) -> Result { + let mut resp = worker::Fetch::Request(worker_req) + .send() + .await + .map_err(|e| HttpError::new(HttpErrorKind::Unknown, e))?; + + let status = http::StatusCode::from_u16(resp.status_code()) + .unwrap_or(http::StatusCode::INTERNAL_SERVER_ERROR); + + // Convert response headers + let mut headers = http::HeaderMap::new(); + let worker_headers = resp.headers(); + for (key, value) in worker_headers.entries() { + if let (Ok(name), Ok(val)) = ( + http::header::HeaderName::try_from(key.as_str()), + http::header::HeaderValue::try_from(value.as_str()), + ) { + headers.insert(name, val); + } + } + + // Convert body: stream via mpsc channel + let body = match resp.stream() { + Ok(byte_stream) => byte_stream_to_http_body(byte_stream).await, + Err(_) => { + // Fall back to reading body as bytes + let body_bytes = resp + .bytes() + .await + .map_err(|e| HttpError::new(HttpErrorKind::Unknown, e))?; + HttpResponseBody::from(Bytes::from(body_bytes)) + } + }; + + let mut http_response = HttpResponse::new(body); + *http_response.status_mut() = status; + *http_response.headers_mut() = headers; + + Ok(http_response) + } +} + +#[async_trait::async_trait] +impl HttpService for FetchService { + async fn call(&self, req: HttpRequest) -> Result { + // Convert http::Request to worker::Request + let method = req.method().to_string(); + let uri = req.uri().to_string(); + let headers = req.headers().clone(); + + let mut worker_req = worker::Request::new(&uri, worker::Method::from(method)) + .map_err(|e| HttpError::new(HttpErrorKind::Unknown, e))?; + + // Copy headers + { + let worker_headers = worker_req + .headers_mut() + .map_err(|e| HttpError::new(HttpErrorKind::Unknown, e))?; + for (key, value) in headers.iter() { + if let Ok(v) = value.to_str() { + let _ = worker_headers.set(key.as_str(), v); + } + } + } + + self.do_fetch(worker_req).await + } +} + +/// Convert a `worker::ByteStream` to an `HttpResponseBody` via mpsc channel. +/// +/// The ByteStream is consumed in a `spawn_local` task (non-Send context). +/// Chunks are sent through an mpsc channel whose receiver implements `Send`, +/// which is then wrapped as a streaming `HttpResponseBody`. +async fn byte_stream_to_http_body(mut stream: worker::ByteStream) -> HttpResponseBody { + let (mut tx, rx) = mpsc::channel(1); + + spawn_local(async move { + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + if tx.send(Ok(Bytes::from(bytes))).await.is_err() { + break; + } + } + Err(e) => { + let _ = tx + .send(Err(HttpError::new(HttpErrorKind::Unknown, e))) + .await; + break; + } + } + } + }); + + let framed = rx.map(|chunk| { + let frame = Frame::data(chunk?); + Ok(frame) + }); + + HttpResponseBody::new(StreamBody::new(framed)) +} diff --git a/crates/runtimes/cf-workers/src/lib.rs b/crates/runtimes/cf-workers/src/lib.rs new file mode 100644 index 0000000..4f594bc --- /dev/null +++ b/crates/runtimes/cf-workers/src/lib.rs @@ -0,0 +1,390 @@ +//! Cloudflare Workers runtime for the S3 proxy gateway. +//! +//! This crate provides implementations of core traits using Cloudflare Workers +//! primitives. Uses the worker crate's `http` feature for standard +//! `http::Request`/`http::Response` types, eliminating manual type conversion. +//! Uses reqwest (which wraps `web_sys::fetch` on WASM) for forward execution. +//! +//! # Architecture +//! +//! ```text +//! Client -> Worker (http::Request via worker's `http` feature) +//! -> resolve request (core resolver or Source Cooperative resolver) +//! -> Forward: reqwest with presigned URL +//! -> Response: LIST XML via object_store, errors, synthetic responses +//! -> NeedsBody: multipart operations via raw signed HTTP +//! ``` +//! +//! # Configuration +//! +//! On Workers, configuration is loaded from: +//! - Environment variables / secrets for simple setups +//! - Workers KV for dynamic configuration +//! - The HTTP config provider for centralized config APIs +//! - **Source Cooperative API** when `SOURCE_API_URL` is set + +mod client; +mod fetch_connector; +mod tracing_layer; + +use client::{FetchHttpExchange, WorkerBackend}; +use source_coop_api::api::{CacheTtls, SourceApiClient}; +use source_coop_api::resolver::SourceCoopResolver; +use source_coop_core::axum::{build_proxy_response, error_response}; +use source_coop_core::config::static_file::{StaticConfig, StaticProvider}; +use source_coop_core::oidc_backend::OidcBackendAuth; +use source_coop_core::proxy::{ + ForwardRequest, HandlerAction, ProxyHandler, RESPONSE_HEADER_ALLOWLIST, +}; +use source_coop_core::resolver::{DefaultResolver, RequestResolver}; +use source_coop_core::sealed_token::TokenKey; +use source_coop_oidc_provider::backend_auth::MaybeOidcAuth; +use source_coop_oidc_provider::jwt::JwtSigner; +use source_coop_oidc_provider::OidcCredentialProvider; +use source_coop_sts::{try_handle_sts, try_parse_sts_request, JwksCache}; + +use axum::body::Body; +use axum::response::Response; +use http::HeaderMap; +use worker::*; + +/// The Worker entry point. +/// +/// With the `http` feature, the worker crate provides standard `http::Request` +/// and `http::Response` types, eliminating the need for manual method/header +/// conversion. +/// +/// Wrangler config (`wrangler.toml`) should bind: +/// - `CONFIG` environment variable or KV namespace for configuration +/// - `VIRTUAL_HOST_DOMAIN` environment variable (optional) +/// - `SOURCE_API_URL` + `SOURCE_API_KEY` for Source Cooperative API mode +#[event(fetch)] +async fn fetch( + req: HttpRequest, + env: Env, + _ctx: Context, +) -> Result> { + // Initialize panic hook for better error messages + console_error_panic_hook::set_once(); + + // Initialize tracing subscriber (idempotent — ignored if already set) + tracing::subscriber::set_global_default(tracing_layer::WorkerSubscriber::new()).ok(); + + let reqwest_client = reqwest::Client::new(); + let jwks_cache = JwksCache::new(reqwest_client.clone(), std::time::Duration::from_secs(900)); + let token_key = load_token_key(&env)?; + + let (parts, worker_body) = req.into_parts(); + let body = Body::new(worker_body); + let method = parts.method; + let uri = parts.uri; + let path = uri.path().to_string(); + let query = uri.query().map(|q| q.to_string()); + let headers = parts.headers; + + // Build OIDC backend auth from env secrets/vars. + let (oidc_auth, oidc_discovery) = load_oidc_auth(&env)?; + + // Intercept OIDC discovery endpoints when OIDC provider is configured. + if let Some(disc) = &oidc_discovery { + if path == "/.well-known/openid-configuration" { + let jwks_uri = format!("{}/.well-known/jwks.json", disc.issuer); + let json = source_coop_oidc_provider::discovery::openid_configuration_json( + &disc.issuer, + &jwks_uri, + ); + return Ok(Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap()); + } + if path == "/.well-known/jwks.json" { + let json = source_coop_oidc_provider::jwks::jwks_json( + disc.signer.public_key(), + disc.signer.kid(), + ); + return Ok(Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap()); + } + } + + // Intercept STS AssumeRoleWithWebIdentity requests before resolver dispatch. + // STS uses STS_CONFIG (falling back to PROXY_CONFIG) for role definitions. + if try_parse_sts_request(query.as_deref()).is_some() { + let config = load_sts_config(&env)?; + if let Some((status, xml)) = + try_handle_sts(query.as_deref(), &config, &jwks_cache, token_key.as_ref()).await + { + return Ok(Response::builder() + .status(status) + .header("content-type", "application/xml") + .body(Body::from(xml)) + .unwrap()); + } + } + + // Source Cooperative API mode: when SOURCE_API_URL is set, resolve backends + // dynamically from the Source API instead of static PROXY_CONFIG. + if let Ok(source_api_url) = env.var("SOURCE_API_URL") { + let source_api_key = env + .var("SOURCE_API_KEY") + .map(|v| v.to_string()) + .map_err(|e| { + worker::Error::RustError(format!( + "SOURCE_API_KEY required when SOURCE_API_URL is set: {}", + e + )) + })?; + + tracing::info!( + source_api_url = source_api_url.to_string(), + "SOURCE_API_URL set, using Source Cooperative API resolver" + ); + + let cache_ttls = load_cache_ttls(&env); + + let api_client = SourceApiClient::new( + client::WorkerHttpClient, + source_api_url.to_string(), + source_api_key, + cache_ttls, + ); + let resolver = SourceCoopResolver::new(api_client); + let handler = ProxyHandler::new(WorkerBackend, resolver).with_oidc_auth(oidc_auth); + + return Ok(handle_action( + method, + &handler, + &reqwest_client, + &path, + query.as_deref(), + &headers, + body, + ) + .await); + } + + let config = load_static_config(&env)?; + let virtual_host_domain = env.var("VIRTUAL_HOST_DOMAIN").ok().map(|v| v.to_string()); + let resolver = DefaultResolver::new(config, virtual_host_domain, token_key); + let handler = ProxyHandler::new(WorkerBackend, resolver).with_oidc_auth(oidc_auth); + + Ok(handle_action( + method, + &handler, + &reqwest_client, + &path, + query.as_deref(), + &headers, + body, + ) + .await) +} + +// ── Two-phase request handling ────────────────────────────────────── + +/// Handle the resolved action for any resolver type. +async fn handle_action( + method: http::Method, + handler: &ProxyHandler, + client: &reqwest::Client, + path: &str, + query: Option<&str>, + headers: &http::HeaderMap, + body: Body, +) -> Response { + let action = handler.resolve_request(method, path, query, headers).await; + + match action { + HandlerAction::Response(result) => build_proxy_response(result), + HandlerAction::Forward(fwd) => forward_to_backend(client, fwd, body).await, + HandlerAction::NeedsBody(pending) => { + let collected = match axum::body::to_bytes(body, usize::MAX).await { + Ok(b) => b, + Err(e) => { + tracing::error!(error = %e, "failed to read request body"); + return error_response(500, "Internal error"); + } + }; + let result = handler.handle_with_body(pending, collected).await; + build_proxy_response(result) + } + } +} + +/// Execute a Forward request via reqwest. +/// +/// On WASM, reqwest wraps `web_sys::fetch` internally. Bodies are collected +/// to bytes since WASM reqwest doesn't support streaming. +async fn forward_to_backend(client: &reqwest::Client, fwd: ForwardRequest, body: Body) -> Response { + let mut req_builder = client.request(fwd.method.clone(), fwd.url.as_str()); + + for (k, v) in fwd.headers.iter() { + req_builder = req_builder.header(k, v); + } + + // Attach body for PUT — collect to bytes since WASM reqwest + // doesn't support wrap_stream + if fwd.method == http::Method::PUT { + match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => { + req_builder = req_builder.body(bytes); + } + Err(e) => { + tracing::error!(error = %e, "failed to read PUT body"); + return error_response(500, "Internal error"); + } + } + } + + let backend_resp = match req_builder.send().await { + Ok(resp) => resp, + Err(e) => { + tracing::error!(error = %e, "forward request failed"); + return error_response(502, "Bad Gateway"); + } + }; + + let status = backend_resp.status().as_u16(); + + // Forward allowlisted response headers + let mut resp_headers = HeaderMap::new(); + for name in RESPONSE_HEADER_ALLOWLIST { + if let Some(v) = backend_resp.headers().get(*name) { + resp_headers.insert(*name, v.clone()); + } + } + + // Read response body as bytes (WASM reqwest doesn't support bytes_stream) + let resp_bytes = match backend_resp.bytes().await { + Ok(b) => b, + Err(e) => { + tracing::error!(error = %e, "failed to read backend response"); + return error_response(502, "Bad Gateway"); + } + }; + + let mut builder = Response::builder().status(status); + for (k, v) in resp_headers.iter() { + builder = builder.header(k, v); + } + + builder.body(Body::from(resp_bytes)).unwrap() +} + +// ── Shared helpers ────────────────────────────────────────────────── + +/// Load a StaticProvider from a named env var (supports both JSON string and JS object). +fn load_config_from_env(env: &Env, var_name: &str) -> Result { + if let Ok(var) = env.var(var_name) { + let config_str = var.to_string(); + tracing::debug!( + var = var_name, + config_len = config_str.len(), + "loaded config as string" + ); + StaticProvider::from_json(&config_str) + .map_err(|e| worker::Error::RustError(format!("{} config error: {}", var_name, e))) + } else { + tracing::debug!(var = var_name, "loading config as object"); + let static_config: StaticConfig = env + .object_var(var_name) + .map_err(|e| worker::Error::RustError(format!("{} config error: {}", var_name, e)))?; + Ok(StaticProvider::from_config(static_config)) + } +} + +fn load_static_config(env: &Env) -> Result { + load_config_from_env(env, "PROXY_CONFIG") +} + +/// Load the optional session token encryption key from the `SESSION_TOKEN_KEY` secret. +fn load_token_key(env: &Env) -> Result> { + match env.secret("SESSION_TOKEN_KEY") { + Ok(val) => { + let key = TokenKey::from_base64(&val.to_string()) + .map_err(|e| worker::Error::RustError(e.to_string()))?; + Ok(Some(key)) + } + Err(_) => Ok(None), + } +} + +/// Load STS config: tries STS_CONFIG first, falls back to PROXY_CONFIG. +fn load_sts_config(env: &Env) -> Result { + load_config_from_env(env, "STS_CONFIG").or_else(|_| load_config_from_env(env, "PROXY_CONFIG")) +} + +type OidcAuth = MaybeOidcAuth; + +struct WorkerOidcDiscovery { + issuer: String, + signer: JwtSigner, +} + +/// Load OIDC provider config from env secrets/vars. +/// +/// Returns `MaybeOidcAuth::Enabled` if both `OIDC_PROVIDER_KEY` (secret) and +/// `OIDC_PROVIDER_ISSUER` (var) are set; otherwise `Disabled`. +fn load_oidc_auth(env: &Env) -> Result<(OidcAuth, Option)> { + let key_pem = match env.secret("OIDC_PROVIDER_KEY") { + Ok(val) => Some(val.to_string()), + Err(_) => None, + }; + let issuer = env.var("OIDC_PROVIDER_ISSUER").ok().map(|v| v.to_string()); + + match (key_pem, issuer) { + (Some(pem), Some(issuer)) => { + let signer = JwtSigner::from_pem(&pem, "proxy-key-1".into(), 300) + .map_err(|e| worker::Error::RustError(format!("OIDC signer error: {e}")))?; + let http = FetchHttpExchange; + let provider = OidcCredentialProvider::new( + signer.clone(), + http, + issuer.clone(), + "sts.amazonaws.com".into(), + ); + let auth = MaybeOidcAuth::Enabled(Box::new( + source_coop_oidc_provider::backend_auth::AwsOidcBackendAuth::new(provider), + )); + let discovery = WorkerOidcDiscovery { issuer, signer }; + Ok((auth, Some(discovery))) + } + _ => Ok((MaybeOidcAuth::Disabled, None)), + } +} + +/// Load cache TTL overrides from environment variables. +fn load_cache_ttls(env: &Env) -> CacheTtls { + let mut cache_ttls = CacheTtls::default(); + if let Ok(v) = env.var("SOURCE_CACHE_TTL_PRODUCT") { + if let Ok(n) = v.to_string().parse::() { + cache_ttls.product = n; + } + } + if let Ok(v) = env.var("SOURCE_CACHE_TTL_DATA_CONNECTION") { + if let Ok(n) = v.to_string().parse::() { + cache_ttls.data_connection = n; + } + } + if let Ok(v) = env.var("SOURCE_CACHE_TTL_PERMISSIONS") { + if let Ok(n) = v.to_string().parse::() { + cache_ttls.permissions = n; + } + } + if let Ok(v) = env.var("SOURCE_CACHE_TTL_ACCOUNT") { + if let Ok(n) = v.to_string().parse::() { + cache_ttls.account = n; + } + } + if let Ok(v) = env.var("SOURCE_CACHE_TTL_API_KEY") { + if let Ok(n) = v.to_string().parse::() { + cache_ttls.api_key = n; + } + } + cache_ttls +} diff --git a/crates/runtimes/cf-workers/src/tracing_layer.rs b/crates/runtimes/cf-workers/src/tracing_layer.rs new file mode 100644 index 0000000..2c52590 --- /dev/null +++ b/crates/runtimes/cf-workers/src/tracing_layer.rs @@ -0,0 +1,117 @@ +//! A lightweight `tracing` layer that routes log output to the Workers +//! `console.log` / `console.error` / `console.warn` APIs. +//! +//! This avoids pulling in `tracing-subscriber` (which depends on `std::time` +//! and other things unavailable on `wasm32`). Instead we implement a minimal +//! [`tracing::Subscriber`] that formats events and forwards them through +//! `worker::console_log!` / `console_error!` / `console_warn!`. + +use tracing::field::{Field, Visit}; +use tracing::span; +use tracing::{Event, Level, Metadata, Subscriber}; + +/// A minimal tracing subscriber that logs to the Workers console. +/// +/// Install once at the start of each request: +/// ```rust,ignore +/// tracing::subscriber::set_global_default(WorkerSubscriber::new()) +/// .ok(); // ignore if already set +/// ``` +pub struct WorkerSubscriber { + max_level: Level, +} + +impl WorkerSubscriber { + pub fn new() -> Self { + Self { + max_level: Level::DEBUG, + } + } + + #[allow(dead_code)] + pub fn with_max_level(mut self, level: Level) -> Self { + self.max_level = level; + self + } +} + +impl Subscriber for WorkerSubscriber { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= &self.max_level + } + + fn new_span(&self, _attrs: &span::Attributes<'_>) -> span::Id { + // We don't track spans — just log events. + span::Id::from_u64(1) + } + + fn record(&self, _span: &span::Id, _values: &span::Record<'_>) {} + fn record_follows_from(&self, _span: &span::Id, _follows: &span::Id) {} + fn event(&self, event: &Event<'_>) { + let metadata = event.metadata(); + let level = metadata.level(); + let target = metadata.target(); + + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + + let msg = if visitor.fields.is_empty() { + format!("[{level}] {target}: {}", visitor.message) + } else { + format!( + "[{level}] {target}: {} {{ {} }}", + visitor.message, + visitor.fields.join(", ") + ) + }; + + // Route to the appropriate console method based on level. + // worker::console_log! and friends are macros that call into JS. + match *level { + Level::ERROR => worker::console_error!("{}", msg), + Level::WARN => worker::console_warn!("{}", msg), + _ => worker::console_log!("{}", msg), + } + } + + fn enter(&self, _span: &span::Id) {} + fn exit(&self, _span: &span::Id) {} +} + +/// Visitor that extracts the `message` field and collects remaining fields +/// into `key=value` pairs. +#[derive(Default)] +struct MessageVisitor { + message: String, + fields: Vec, +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.message = format!("{:?}", value); + } else { + self.fields.push(format!("{}={:?}", field.name(), value)); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message = value.to_string(); + } else { + self.fields.push(format!("{}=\"{}\"", field.name(), value)); + } + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.fields.push(format!("{}={}", field.name(), value)); + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.fields.push(format!("{}={}", field.name(), value)); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.fields.push(format!("{}={}", field.name(), value)); + } +} diff --git a/crates/runtimes/cf-workers/wrangler.toml b/crates/runtimes/cf-workers/wrangler.toml new file mode 100644 index 0000000..3081e56 --- /dev/null +++ b/crates/runtimes/cf-workers/wrangler.toml @@ -0,0 +1,112 @@ +compatibility_date = "2024-09-23" +main = "build/worker/shim.mjs" +name = "source-coop-proxy" + +[build] +command = "cargo install worker-build && worker-build --release" + +[vars] +# SOURCE_API_URL = "https://staging.source.coop" +VIRTUAL_HOST_DOMAIN = "s3.local" + +# For production, consider storing this in Workers KV or a Secrets binding. +[vars.PROXY_CONFIG] + +[[vars.PROXY_CONFIG.buckets]] +allowed_roles = [] +anonymous_access = true +backend_type = "s3" +name = "public-data" + +[vars.PROXY_CONFIG.buckets.backend_options] +access_key_id = "minioadmin" +bucket_name = "public-data" +endpoint = "http://localhost:9000" +region = "us-east-1" +secret_access_key = "minioadmin" + +[[vars.PROXY_CONFIG.buckets]] +allowed_roles = [] +anonymous_access = false +backend_type = "s3" +name = "private-uploads" + +[vars.PROXY_CONFIG.buckets.backend_options] +access_key_id = "minioadmin" +bucket_name = "private-uploads" +endpoint = "http://localhost:9000" +region = "us-east-1" +secret_access_key = "minioadmin" + +[[vars.PROXY_CONFIG.buckets]] +allowed_roles = [] +anonymous_access = true +backend_prefix = "cholmes/" +backend_type = "s3" +name = "cholmes" + +[vars.PROXY_CONFIG.buckets.backend_options] +bucket_name = "us-west-2.opendata.source.coop" +endpoint = "https://s3.us-west-2.amazonaws.com" +region = "us-west-2" +skip_signature = "true" + +[[vars.PROXY_CONFIG.buckets]] +allowed_roles = [] +anonymous_access = true +backend_prefix = "harvard-lil/" +backend_type = "s3" +name = "harvard-lil" + +[vars.PROXY_CONFIG.buckets.backend_options] +bucket_name = "us-west-2.opendata.source.coop" +endpoint = "https://s3.us-west-2.amazonaws.com" +region = "us-west-2" +skip_signature = "true" + +[[vars.PROXY_CONFIG.credentials]] +access_key_id = "AKLOCAL0000000000001" +created_at = "2024-01-01T00:00:00Z" +enabled = true +principal_name = "dev-user" +secret_access_key = "localdev/secret/key/00000000000000000000" + +[[vars.PROXY_CONFIG.credentials.allowed_scopes]] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +bucket = "public-data" +prefixes = [] + +[[vars.PROXY_CONFIG.credentials.allowed_scopes]] +actions = [ + "get_object", + "head_object", + "put_object", + "list_bucket", + "create_multipart_upload", + "upload_part", + "complete_multipart_upload", +] +bucket = "private-uploads" +prefixes = [] + +[vars.STS_CONFIG] +[[vars.STS_CONFIG.roles]] +max_session_duration_secs = 3600 +name = "Source Cooperative User" +role_id = "source-coop-user" +subject_conditions = ["*"] +trusted_oidc_issuers = [ + "https://auth.source.coop", + "https://auth.staging.source.coop", + "https://optimistic-jackson-tvx6h5ig8s.projects.oryapis.com", +] + +[[vars.STS_CONFIG.roles.allowed_scopes]] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +bucket = "{sub}" # this user +prefixes = [] + +[[vars.STS_CONFIG.roles.allowed_scopes]] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +bucket = "private-uploads" +prefixes = [] diff --git a/crates/runtimes/server/Cargo.toml b/crates/runtimes/server/Cargo.toml new file mode 100644 index 0000000..351d09d --- /dev/null +++ b/crates/runtimes/server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "source-coop-server" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Tokio/Hyper runtime for the S3 proxy gateway" + +[dependencies] +source-coop-core = { workspace = true, features = ["axum", "azure", "gcp"] } +source-coop-sts.workspace = true +source-coop-oidc-provider.workspace = true +source-coop-api.workspace = true +axum = { workspace = true, features = ["json", "tokio", "http1", "http2"] } +tower-service.workspace = true +tokio.workspace = true +http.workspace = true +bytes.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +serde.workspace = true +toml.workspace = true +reqwest = { workspace = true, features = ["stream"] } +thiserror.workspace = true +object_store.workspace = true +futures.workspace = true +http-body-util.workspace = true diff --git a/crates/runtimes/server/README.md b/crates/runtimes/server/README.md new file mode 100644 index 0000000..355e500 --- /dev/null +++ b/crates/runtimes/server/README.md @@ -0,0 +1,122 @@ +# source-coop-server + +Tokio/Hyper runtime for the S3 proxy gateway. This is the container-deployment crate — it wires the core library into a production HTTP server using native Rust async I/O. + +## What This Crate Provides + +A `ProxyBackend` implementation plus a server binary: + +**`ServerBackend`** — implements `ProxyBackend`. Provides `create_signer()` for presigned URL generation (GET/HEAD/PUT/DELETE), `create_store()` for LIST operations, and `send_raw()` via reqwest for multipart uploads. All Forward operations (GET/HEAD/PUT/DELETE) execute presigned URLs via reqwest; GET response bodies and PUT request bodies stream without buffering. + +**`server::run()`** — starts a Hyper HTTP server that accepts connections and delegates to `ProxyHandler` with a `DefaultResolver`. Supports both path-style (`/bucket/key`) and virtual-hosted-style (`bucket.s3.example.com/key`) routing via the resolver's `virtual_host_domain` setting. + +## Module Overview + +``` +src/ +├── lib.rs Crate root +├── body.rs ProxyResult → Hyper response conversion (Bytes/Empty only) +├── client.rs ServerBackend implementing ProxyBackend, ReqwestHttpExchange +├── server.rs Hyper server setup, two-phase request handling, Forward execution +└── bin/ + └── source-coop-proxy.rs CLI binary entry point +``` + +## Binary Usage + +```bash +cargo build --release -p source-coop-server + +# Minimal +./target/release/source-coop-proxy --config config.toml + +# Full options +./target/release/source-coop-proxy \ + --config /etc/source-coop-proxy/config.toml \ + --listen 0.0.0.0:9000 \ + --domain s3.local + +# Enable OIDC backend auth (exchange self-signed JWTs for cloud credentials) +OIDC_PROVIDER_KEY="$(cat private_key.pem)" \ +OIDC_PROVIDER_ISSUER="https://data.example.com" \ +./target/release/source-coop-proxy --config config.toml + +# Environment variable for log level +RUST_LOG=source_coop=debug ./target/release/source-coop-proxy --config config.toml +``` + +## Docker + +```bash +docker build -t source-coop-proxy . +docker run -v ./config.toml:/etc/source-coop-proxy/config.toml -p 8080:8080 source-coop-proxy +``` + +## Using a Different Config Provider + +The default binary uses `StaticProvider` (TOML file) wrapped in `CachedProvider`. The `run()` function accepts any `ConfigProvider` and wraps it in a `DefaultResolver` internally. To use a different provider, modify the binary or write your own: + +```rust +use source_coop_core::config::cached::CachedProvider; +use source_coop_core::config::http::HttpProvider; // requires config-http feature +use source_coop_server::server::{run, ServerConfig}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let base = HttpProvider::new( + "https://config-api.internal:8080".into(), + Some("Bearer my-token".into()), + ); + let config = CachedProvider::new(base, Duration::from_secs(300)); + + run(config, ServerConfig::default()).await +} +``` + +## Using a Custom Request Resolver + +For full control over request routing and authorization, you can bypass `run()` and wire up a `ProxyHandler` with a custom `RequestResolver` directly. This is useful when your URL namespace doesn't follow the standard S3 bucket/key pattern, or when authorization is handled by an external service. + +```rust +use source_coop_core::proxy::ProxyHandler; +use source_coop_core::resolver::{RequestResolver, ResolvedAction}; +use source_coop_core::error::ProxyError; + +#[derive(Clone)] +struct MyResolver { /* ... */ } + +impl RequestResolver for MyResolver { + async fn resolve( + &self, + method: &http::Method, + path: &str, + query: Option<&str>, + headers: &http::HeaderMap, + ) -> Result { + // Custom routing: parse the URL, authenticate, authorize, + // and return a ResolvedAction::Proxy or ResolvedAction::Response. + todo!() + } +} + +// Then create the handler directly: +let backend = ServerBackend::new(); +let handler = ProxyHandler::new(backend, MyResolver::new()); + +// Use handler.resolve_request() in your Hyper service — returns HandlerAction. +``` + +See `crates/libs/source-coop/src/resolver.rs` for a complete example. + +## Streaming Behavior + +For **GET** responses, the handler generates a presigned URL and returns a `Forward` action. The server executes the URL via reqwest and streams the response body through Hyper using `bytes_stream()` — no buffering. + +For **PUT** requests, the handler generates a presigned URL and returns a `Forward` action. The server streams the Hyper `Incoming` body directly to the presigned URL via `reqwest::Body::wrap_stream()` — no body materialization. + +For **HEAD/DELETE** responses, the handler generates a presigned URL. The server executes it and returns the status + headers. + +For **LIST** responses, `object_store` handles the request internally and the handler returns a `Response` with XML body. + +For **multipart uploads**, operations are sent as raw signed HTTP requests via `reqwest`. The request body is materialized to `Bytes` first (multipart XML payloads are small). diff --git a/crates/runtimes/server/src/bin/source-coop-proxy.rs b/crates/runtimes/server/src/bin/source-coop-proxy.rs new file mode 100644 index 0000000..5b64f88 --- /dev/null +++ b/crates/runtimes/server/src/bin/source-coop-proxy.rs @@ -0,0 +1,81 @@ +//! Source Cooperative Proxy Server binary. +//! +//! Usage: +//! source-coop-proxy --config config.toml [--sts-config sts.toml] [--listen 0.0.0.0:8080] [--domain s3.local] + +use source_coop_core::config::cached::CachedProvider; +use source_coop_core::config::static_file::StaticProvider; +use source_coop_core::sealed_token::TokenKey; +use source_coop_server::server::{run, ServerConfig}; +use std::net::SocketAddr; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "source_coop=info".into()), + ) + .init(); + + let args: Vec = std::env::args().collect(); + + let config_path = args + .iter() + .position(|a| a == "--config") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()) + .unwrap_or("config.toml"); + + let listen_addr: SocketAddr = args + .iter() + .position(|a| a == "--listen") + .and_then(|i| args.get(i + 1)) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| ([0, 0, 0, 0], 8080).into()); + + let domain = args + .iter() + .position(|a| a == "--domain") + .and_then(|i| args.get(i + 1)) + .cloned(); + + let sts_config_path = args + .iter() + .position(|a| a == "--sts-config") + .and_then(|i| args.get(i + 1)) + .map(|s| s.as_str()); + + tracing::info!(config = %config_path, listen = %listen_addr, "starting source-coop-proxy"); + + let base_config = StaticProvider::from_file(config_path)?; + let sts_base = match sts_config_path { + Some(path) => { + tracing::info!(sts_config = %path, "using separate STS config"); + StaticProvider::from_file(path)? + } + None => base_config.clone(), + }; + + let config = CachedProvider::new(base_config, Duration::from_secs(60)); + let sts_config = CachedProvider::new(sts_base, Duration::from_secs(60)); + + let token_key = std::env::var("SESSION_TOKEN_KEY") + .ok() + .map(|v| TokenKey::from_base64(&v)) + .transpose()?; + + let oidc_provider_key = std::env::var("OIDC_PROVIDER_KEY").ok(); + let oidc_provider_issuer = std::env::var("OIDC_PROVIDER_ISSUER").ok(); + + let server_config = ServerConfig { + listen_addr, + virtual_host_domain: domain, + token_key, + oidc_provider_key, + oidc_provider_issuer, + }; + + run(config, sts_config, server_config).await +} diff --git a/crates/runtimes/server/src/client.rs b/crates/runtimes/server/src/client.rs new file mode 100644 index 0000000..a650248 --- /dev/null +++ b/crates/runtimes/server/src/client.rs @@ -0,0 +1,130 @@ +//! Server backend using reqwest for raw HTTP and default object_store connector. + +use bytes::Bytes; +use http::HeaderMap; +use object_store::list::PaginatedListStore; +use object_store::signer::Signer; +use source_coop_core::backend::{ + build_paginated_list_store, build_signer, ProxyBackend, RawResponse, +}; +use source_coop_core::error::ProxyError; +use source_coop_core::types::BucketConfig; +use source_coop_oidc_provider::{HttpExchange, OidcProviderError}; +use std::sync::Arc; + +/// Backend for the Tokio/Hyper server runtime. +/// +/// Uses reqwest for raw HTTP (multipart operations) and the default +/// object_store HTTP connector for high-level operations. +#[derive(Clone)] +pub struct ServerBackend { + client: reqwest::Client, +} + +impl ServerBackend { + pub fn new() -> Self { + Self { + client: reqwest::Client::builder() + .pool_max_idle_per_host(20) + .build() + .expect("failed to build reqwest client"), + } + } + + /// Access the underlying reqwest client for Forward request execution. + pub fn client(&self) -> &reqwest::Client { + &self.client + } +} + +impl Default for ServerBackend { + fn default() -> Self { + Self::new() + } +} + +impl ProxyBackend for ServerBackend { + fn create_paginated_store( + &self, + config: &BucketConfig, + ) -> Result, ProxyError> { + build_paginated_list_store(config, |b| b) + } + + fn create_signer(&self, config: &BucketConfig) -> Result, ProxyError> { + build_signer(config) + } + + async fn send_raw( + &self, + method: http::Method, + url: String, + headers: HeaderMap, + body: Bytes, + ) -> Result { + tracing::debug!( + method = %method, + url = %url, + "server: sending raw backend request via reqwest" + ); + + let mut req_builder = self.client.request(method, &url); + + for (key, value) in headers.iter() { + req_builder = req_builder.header(key, value); + } + + if !body.is_empty() { + req_builder = req_builder.body(body); + } + + let response = req_builder.send().await.map_err(|e| { + tracing::error!(error = %e, "reqwest raw request failed"); + ProxyError::BackendError(e.to_string()) + })?; + + let status = response.status().as_u16(); + let resp_headers = response.headers().clone(); + let resp_body = response.bytes().await.map_err(|e| { + ProxyError::BackendError(format!("failed to read raw response body: {}", e)) + })?; + + Ok(RawResponse { + status, + headers: resp_headers, + body: resp_body, + }) + } +} + +/// [`HttpExchange`] implementation using reqwest (native). +#[derive(Clone)] +pub struct ReqwestHttpExchange { + client: reqwest::Client, +} + +impl ReqwestHttpExchange { + pub fn new(client: reqwest::Client) -> Self { + Self { client } + } +} + +impl HttpExchange for ReqwestHttpExchange { + async fn post_form( + &self, + url: &str, + form: &[(&str, &str)], + ) -> Result { + let resp = self + .client + .post(url) + .form(form) + .send() + .await + .map_err(|e| OidcProviderError::HttpError(e.to_string()))?; + + resp.text() + .await + .map_err(|e| OidcProviderError::HttpError(e.to_string())) + } +} diff --git a/crates/runtimes/server/src/lib.rs b/crates/runtimes/server/src/lib.rs new file mode 100644 index 0000000..3fcd749 --- /dev/null +++ b/crates/runtimes/server/src/lib.rs @@ -0,0 +1,10 @@ +//! Tokio/axum runtime for the S3 proxy gateway. +//! +//! This crate provides concrete implementations of the core traits for a +//! standard server environment using Tokio and axum. +//! +//! - [`client::ServerBackend`] — implements `ProxyBackend` using reqwest + object_store +//! - [`server::run`] — starts the axum HTTP server + +pub mod client; +pub mod server; diff --git a/crates/runtimes/server/src/server.rs b/crates/runtimes/server/src/server.rs new file mode 100644 index 0000000..7ff2c60 --- /dev/null +++ b/crates/runtimes/server/src/server.rs @@ -0,0 +1,278 @@ +//! HTTP server using axum, wiring everything together. + +use crate::client::{ReqwestHttpExchange, ServerBackend}; +use axum::body::Body; +use axum::extract::State; +use axum::response::Response; +use axum::Router; +use futures::TryStreamExt; +use http::HeaderMap; +use http_body_util::BodyStream; +use source_coop_core::axum::{build_proxy_response, error_response}; +use source_coop_core::config::ConfigProvider; +use source_coop_core::proxy::{ + ForwardRequest, HandlerAction, ProxyHandler, RESPONSE_HEADER_ALLOWLIST, +}; +use source_coop_core::resolver::DefaultResolver; +use source_coop_core::sealed_token::TokenKey; +use source_coop_oidc_provider::backend_auth::MaybeOidcAuth; +use source_coop_oidc_provider::jwt::JwtSigner; +use source_coop_oidc_provider::OidcCredentialProvider; +use source_coop_sts::{try_handle_sts, JwksCache}; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::TcpListener; + +/// Server configuration. +pub struct ServerConfig { + pub listen_addr: SocketAddr, + /// The base domain for virtual-hosted-style requests (e.g., "s3.example.com"). + /// If set, requests to `{bucket}.s3.example.com` use virtual-hosted style. + pub virtual_host_domain: Option, + /// Optional AES-256-GCM key for self-contained encrypted session tokens. + pub token_key: Option, + /// PEM-encoded RSA private key for OIDC provider (minting JWTs for backend auth). + pub oidc_provider_key: Option, + /// Issuer URL for the OIDC provider (must be publicly reachable for JWKS discovery). + pub oidc_provider_issuer: Option, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + listen_addr: ([0, 0, 0, 0], 8080).into(), + virtual_host_domain: None, + token_key: None, + oidc_provider_key: None, + oidc_provider_issuer: None, + } + } +} + +type OidcAuth = MaybeOidcAuth; + +struct AppState { + handler: ProxyHandler, OidcAuth>, + reqwest_client: reqwest::Client, + sts_config: P, + jwks_cache: JwksCache, + token_key: Option, + /// OIDC discovery data (issuer + signer), set when OIDC provider is configured. + oidc_discovery: Option, +} + +struct OidcDiscovery { + issuer: String, + signer: JwtSigner, +} + +/// Run the S3 proxy server. +/// +/// # Example +/// +/// ```rust,ignore +/// use source_coop_core::config::static_file::StaticProvider; +/// use source_coop_server::server::{run, ServerConfig}; +/// +/// #[tokio::main] +/// async fn main() { +/// let config = StaticProvider::from_file("config.toml").unwrap(); +/// let sts_config = config.clone(); +/// let server_config = ServerConfig { +/// listen_addr: ([0, 0, 0, 0], 8080).into(), +/// virtual_host_domain: Some("s3.local".to_string()), +/// ..Default::default() +/// }; +/// run(config, sts_config, server_config).await.unwrap(); +/// } +/// ``` +pub async fn run

( + config: P, + sts_config: P, + server_config: ServerConfig, +) -> Result<(), Box> +where + P: ConfigProvider + Send + Sync + 'static, +{ + let backend = ServerBackend::new(); + let reqwest_client = backend.client().clone(); + let jwks_cache = JwksCache::new(reqwest_client.clone(), Duration::from_secs(900)); + let token_key = server_config.token_key; + let resolver = + DefaultResolver::new(config, server_config.virtual_host_domain, token_key.clone()); + + // Build OIDC provider if both key and issuer are configured. + let (oidc_auth, oidc_discovery) = match ( + &server_config.oidc_provider_key, + &server_config.oidc_provider_issuer, + ) { + (Some(key_pem), Some(issuer)) => { + let signer = JwtSigner::from_pem(key_pem, "proxy-key-1".into(), 300) + .map_err(|e| format!("failed to create OIDC JWT signer: {e}"))?; + let http = ReqwestHttpExchange::new(reqwest_client.clone()); + let provider = OidcCredentialProvider::new( + signer.clone(), + http, + issuer.clone(), + "sts.amazonaws.com".into(), + ); + let auth = MaybeOidcAuth::Enabled(Box::new( + source_coop_oidc_provider::backend_auth::AwsOidcBackendAuth::new(provider), + )); + let discovery = OidcDiscovery { + issuer: issuer.clone(), + signer, + }; + (auth, Some(discovery)) + } + _ => (MaybeOidcAuth::Disabled, None), + }; + + let handler = ProxyHandler::new(backend, resolver).with_oidc_auth(oidc_auth); + + let state = Arc::new(AppState { + handler, + reqwest_client, + sts_config, + jwks_cache, + token_key, + oidc_discovery, + }); + + let app = Router::new() + .fallback(request_handler::

) + .with_state(state); + + let listener = TcpListener::bind(server_config.listen_addr).await?; + tracing::info!("listening on {}", server_config.listen_addr); + + axum::serve(listener, app).await?; + Ok(()) +} + +async fn request_handler( + State(state): State>>, + req: axum::extract::Request, +) -> Response { + let (parts, body) = req.into_parts(); + let method = parts.method; + let uri = parts.uri; + let path = uri.path().to_string(); + let query = uri.query().map(|q| q.to_string()); + let headers = parts.headers; + + tracing::debug!( + method = %method, + uri = %uri, + "incoming request" + ); + + // Intercept OIDC discovery endpoints when OIDC provider is configured. + if let Some(disc) = &state.oidc_discovery { + if path == "/.well-known/openid-configuration" { + let jwks_uri = format!("{}/.well-known/jwks.json", disc.issuer); + let json = source_coop_oidc_provider::discovery::openid_configuration_json( + &disc.issuer, + &jwks_uri, + ); + return Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap(); + } + if path == "/.well-known/jwks.json" { + let json = source_coop_oidc_provider::jwks::jwks_json( + disc.signer.public_key(), + disc.signer.kid(), + ); + return Response::builder() + .status(200) + .header("content-type", "application/json") + .body(Body::from(json)) + .unwrap(); + } + } + + // Intercept STS AssumeRoleWithWebIdentity requests + if let Some((status, xml)) = try_handle_sts( + query.as_deref(), + &state.sts_config, + &state.jwks_cache, + state.token_key.as_ref(), + ) + .await + { + return Response::builder() + .status(status) + .header("content-type", "application/xml") + .body(Body::from(xml)) + .unwrap(); + } + + let action = state + .handler + .resolve_request(method, &path, query.as_deref(), &headers) + .await; + + match action { + HandlerAction::Response(result) => build_proxy_response(result), + HandlerAction::Forward(fwd) => forward_to_backend(&state.reqwest_client, fwd, body).await, + HandlerAction::NeedsBody(pending) => { + let collected = match axum::body::to_bytes(body, usize::MAX).await { + Ok(b) => b, + Err(e) => { + tracing::error!(error = %e, "failed to read request body"); + return error_response(500, "Internal error"); + } + }; + let result = state.handler.handle_with_body(pending, collected).await; + build_proxy_response(result) + } + } +} + +/// Execute a Forward request via reqwest, streaming both request and response bodies. +async fn forward_to_backend(client: &reqwest::Client, fwd: ForwardRequest, body: Body) -> Response { + let mut req_builder = client.request(fwd.method.clone(), fwd.url.as_str()); + + for (k, v) in fwd.headers.iter() { + req_builder = req_builder.header(k, v); + } + + // Attach streaming body for PUT + if fwd.method == http::Method::PUT { + let body_stream = + BodyStream::new(body).try_filter_map(|frame| async move { Ok(frame.into_data().ok()) }); + req_builder = req_builder.body(reqwest::Body::wrap_stream(body_stream)); + } + + let backend_resp = match req_builder.send().await { + Ok(resp) => resp, + Err(e) => { + tracing::error!(error = %e, "forward request failed"); + return error_response(502, "Bad Gateway"); + } + }; + + let status = backend_resp.status().as_u16(); + + // Forward allowlisted response headers + let mut resp_headers = HeaderMap::new(); + for name in RESPONSE_HEADER_ALLOWLIST { + if let Some(v) = backend_resp.headers().get(*name) { + resp_headers.insert(*name, v.clone()); + } + } + + // Stream the response body + let body = Body::from_stream(backend_resp.bytes_stream()); + + let mut builder = Response::builder().status(status); + for (k, v) in resp_headers.iter() { + builder = builder.header(k, v); + } + + builder.body(body).unwrap() +} diff --git a/deploy/.gitignore b/deploy/.gitignore deleted file mode 100644 index f60797b..0000000 --- a/deploy/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -*.js -!jest.config.js -*.d.ts -node_modules - -# CDK asset staging directory -.cdk.staging -cdk.out diff --git a/deploy/.npmignore b/deploy/.npmignore deleted file mode 100644 index c1d6d45..0000000 --- a/deploy/.npmignore +++ /dev/null @@ -1,6 +0,0 @@ -*.ts -!*.d.ts - -# CDK asset staging directory -.cdk.staging -cdk.out diff --git a/deploy/.nvmrc b/deploy/.nvmrc deleted file mode 100644 index 2bd5a0a..0000000 --- a/deploy/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -22 diff --git a/deploy/README.md b/deploy/README.md deleted file mode 100644 index b88e456..0000000 --- a/deploy/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Deployment - -This directory contains deployment tooling to create and manage the AWS infrastructure for the Source Data Proxy. - -It embraces an "Infrastructure as Code" approach via [AWS CDK](https://docs.aws.amazon.com/cdk/). Deployments should be triggered via Github Actions. diff --git a/deploy/bin/deploy.ts b/deploy/bin/deploy.ts deleted file mode 100644 index 5287996..0000000 --- a/deploy/bin/deploy.ts +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env node -import * as cdk from "aws-cdk-lib"; -import { DataProxyStack } from "../lib/data-proxy-stack"; -import { Tags } from "aws-cdk-lib"; - -const stage = process.env.STAGE || "dev"; -const vpcId = process.env.VPC_ID; -if (!vpcId) { - throw new Error("VPC_ID is not set"); -} -const certificateArn = process.env.CERTIFICATE_ARN; -if (!certificateArn) { - throw new Error("CERTIFICATE_ARN is not set"); -} -const taskCount = process.env.TASK_COUNT || 1; -const sourceApiUrl = process.env.SOURCE_API_URL || "https://s2.source.coop"; - -const app = new cdk.App(); -const stack = new DataProxyStack(app, `DataProxy-${stage}`, { - vpcId, - proxyDomain: `vercel-api-${stage}.internal`, - proxyDesiredCount: Number(taskCount), - sourceApiUrl, - env: { - account: process.env.AWS_ACCOUNT_ID, - region: process.env.AWS_REGION, - }, - certificateArn, -}); - -Tags.of(stack).add("Cfn-Stack", stack.stackName, { - applyToLaunchedInstances: true, -}); diff --git a/deploy/cdk.json b/deploy/cdk.json deleted file mode 100644 index 8dbbaa8..0000000 --- a/deploy/cdk.json +++ /dev/null @@ -1,99 +0,0 @@ -{ - "app": "npx ts-node --prefer-ts-exts bin/deploy.ts", - "watch": { - "include": [ - "**" - ], - "exclude": [ - "README.md", - "cdk*.json", - "**/*.d.ts", - "**/*.js", - "tsconfig.json", - "package*.json", - "yarn.lock", - "node_modules", - "test" - ] - }, - "context": { - "@aws-cdk/aws-lambda:recognizeLayerVersion": true, - "@aws-cdk/core:checkSecretUsage": true, - "@aws-cdk/core:target-partitions": [ - "aws", - "aws-cn" - ], - "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, - "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, - "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, - "@aws-cdk/aws-iam:minimizePolicies": true, - "@aws-cdk/core:validateSnapshotRemovalPolicy": true, - "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, - "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, - "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, - "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, - "@aws-cdk/core:enablePartitionLiterals": true, - "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, - "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, - "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, - "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, - "@aws-cdk/aws-route53-patters:useCertificate": true, - "@aws-cdk/customresources:installLatestAwsSdkDefault": false, - "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, - "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, - "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, - "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, - "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, - "@aws-cdk/aws-redshift:columnId": true, - "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, - "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, - "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, - "@aws-cdk/aws-kms:aliasNameRef": true, - "@aws-cdk/aws-kms:applyImportedAliasPermissionsToPrincipal": true, - "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, - "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, - "@aws-cdk/aws-efs:denyAnonymousAccess": true, - "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, - "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, - "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, - "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, - "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, - "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, - "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, - "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, - "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, - "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, - "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, - "@aws-cdk/aws-eks:nodegroupNameAttribute": true, - "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, - "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, - "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, - "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, - "@aws-cdk/core:explicitStackTags": true, - "@aws-cdk/aws-ecs:enableImdsBlockingDeprecatedFeature": false, - "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, - "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, - "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": true, - "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, - "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, - "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, - "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, - "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true, - "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, - "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, - "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, - "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, - "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, - "@aws-cdk/core:enableAdditionalMetadataCollection": true, - "@aws-cdk/aws-lambda:createNewPoliciesWithAddToRolePolicy": false, - "@aws-cdk/aws-s3:setUniqueReplicationRoleName": true, - "@aws-cdk/aws-events:requireEventBusPolicySid": true, - "@aws-cdk/core:aspectPrioritiesMutating": true, - "@aws-cdk/aws-dynamodb:retainTableReplica": true, - "@aws-cdk/aws-stepfunctions:useDistributedMapResultWriterV2": true, - "@aws-cdk/s3-notifications:addS3TrustKeyPolicyForSnsSubscriptions": true, - "@aws-cdk/aws-ec2:requirePrivateSubnetsForEgressOnlyInternetGateway": true, - "@aws-cdk/aws-s3:publicAccessBlockedByDefault": true, - "@aws-cdk/aws-lambda:useCdkManagedLogGroup": true - } -} diff --git a/deploy/lib/data-proxy-stack.ts b/deploy/lib/data-proxy-stack.ts deleted file mode 100644 index 180cd94..0000000 --- a/deploy/lib/data-proxy-stack.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import { aws_ec2 as ec2 } from "aws-cdk-lib"; -import { Construct } from "constructs"; -import { VercelApiProxy } from "./vercel-api-proxy"; -import { SourceDataProxy } from "./source-data-proxy"; - -interface DataProxyStackProps extends cdk.StackProps { - vpcId: string; - proxyDomain: string; - sourceApiUrl: string; - proxyDesiredCount: number; - certificateArn: string; -} - -export class DataProxyStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: DataProxyStackProps) { - super(scope, id, props); - - const vpc = ec2.Vpc.fromLookup(this, "vpc", { vpcId: props.vpcId }); - - // Create Vercel API proxy (existing functionality) - const vercelApiProxy = new VercelApiProxy(this, "vercel-api-proxy", { - vpc, - proxyDomain: props.proxyDomain, - }); - - new SourceDataProxy(this, "source-data-proxy", { - vpc, - environment: { - RUST_LOG: "info", - SOURCE_API_PROXY_URL: vercelApiProxy.url, - SOURCE_API_URL: props.sourceApiUrl, - }, - desiredCount: props.proxyDesiredCount, - certificateArn: props.certificateArn, - }); - } -} diff --git a/deploy/lib/source-data-proxy.ts b/deploy/lib/source-data-proxy.ts deleted file mode 100644 index b739585..0000000 --- a/deploy/lib/source-data-proxy.ts +++ /dev/null @@ -1,127 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import { - aws_ec2 as ec2, - aws_ecs as ecs, - aws_ecs_patterns as ecs_patterns, - aws_logs as logs, - aws_secretsmanager as secretsmanager, - aws_elasticloadbalancingv2 as elbv2, -} from "aws-cdk-lib"; -import { Certificate } from "aws-cdk-lib/aws-certificatemanager"; -import { Construct } from "constructs"; - -interface SourceDataProxyProps { - vpc: ec2.IVpc; - desiredCount: number; - environment: Record; - certificateArn: string; -} - -export class SourceDataProxy extends Construct { - public readonly service: ecs_patterns.ApplicationLoadBalancedFargateService; - - constructor(scope: Construct, id: string, props: SourceDataProxyProps) { - super(scope, id); - - const stack = cdk.Stack.of(this); - - const cluster = new ecs.Cluster(this, "cluster", { - clusterName: `${stack.stackName}-cluster`, - vpc: props.vpc, - enableFargateCapacityProviders: true, - containerInsightsV2: ecs.ContainerInsights.ENHANCED, - }); - - const sourceApiKeySecret = new secretsmanager.Secret( - this, - "source-api-key", - { - secretName: `${stack.stackName}-source-api-key`, - description: - "API Key used to make authenticated requests to the Source API on Vercel", - } - ); - - // Create Application Load Balanced Fargate Service using the pattern - this.service = new ecs_patterns.ApplicationLoadBalancedFargateService( - this, - "service", - { - serviceName: `${stack.stackName}-proxy`, - cluster, - cpu: 4 * 1024, // 4 vCPU - desiredCount: props.desiredCount, - memoryLimitMiB: 12 * 1024, // 12 GB - taskImageOptions: { - image: ecs.ContainerImage.fromAsset("../", { - buildArgs: { - BUILDPLATFORM: "linux/amd64", - TARGETPLATFORM: "linux/amd64", - }, - }), - containerPort: 8080, - family: `${stack.stackName}-proxy`, - environment: props.environment, - secrets: { - SOURCE_API_KEY: ecs.Secret.fromSecretsManager(sourceApiKeySecret), - }, - logDriver: ecs.LogDrivers.awsLogs({ - streamPrefix: "ecs", - logGroup: new logs.LogGroup(this, "log-group", { - logGroupName: `/ecs/${stack.stackName}-proxy`, - retention: logs.RetentionDays.ONE_MONTH, - }), - mode: ecs.AwsLogDriverMode.NON_BLOCKING, - maxBufferSize: cdk.Size.mebibytes(25), - }), - }, - runtimePlatform: { - cpuArchitecture: ecs.CpuArchitecture.X86_64, - operatingSystemFamily: ecs.OperatingSystemFamily.LINUX, - }, - publicLoadBalancer: true, - loadBalancerName: `${stack.stackName}-alb`, - protocol: elbv2.ApplicationProtocol.HTTPS, - listenerPort: 443, - certificate: Certificate.fromCertificateArn( - this, - "certificate", - props.certificateArn - ), - enableExecuteCommand: true, - circuitBreaker: { rollback: true }, - assignPublicIp: true, - capacityProviderStrategies: [ - { - capacityProvider: "FARGATE_SPOT", - // Prefer spot instances over on-demand instances - weight: 2, - }, - { - capacityProvider: "FARGATE", - // Use on-demand instances as a fallback - weight: 1, - }, - ], - } - ); - - if (this.service.taskDefinition.executionRole) { - sourceApiKeySecret.grantRead(this.service.taskDefinition.executionRole); - } - - // Output the ALB DNS name - new cdk.CfnOutput(this, "alb-dns", { - value: this.service.loadBalancer.loadBalancerDnsName, - description: "Application Load Balancer DNS name", - exportName: `${cdk.Stack.of(this).stackName}-alb-dns`, - }); - - // Output the service name - new cdk.CfnOutput(this, "service-name", { - value: this.service.service.serviceName, - description: "ECS Service name", - exportName: `${cdk.Stack.of(this).stackName}-service-name`, - }); - } -} diff --git a/deploy/lib/vercel-api-proxy.ts b/deploy/lib/vercel-api-proxy.ts deleted file mode 100644 index d8e22e6..0000000 --- a/deploy/lib/vercel-api-proxy.ts +++ /dev/null @@ -1,113 +0,0 @@ -import * as cdk from "aws-cdk-lib"; -import { - aws_ec2 as ec2, - aws_iam as iam, - aws_route53 as route53, - aws_route53_targets as route53_targets, -} from "aws-cdk-lib"; -import { Construct } from "constructs"; - -interface VercelApiProxyProps { - vpc: ec2.IVpc; - proxyDomain: string; -} - -export class VercelApiProxy extends Construct { - public readonly url: string; - /** - * To work around Vercel's firewall, we must proxy all requests for the Proxy API through - * a Squid proxy. This will allow us to have a stable IP address for the Proxy API which - * we can add to the Vercel firewall's bypass list. This allows us to retain ephemeral IP - * addresses for the Proxy API and to avoid using other techniques like passing data - * through a NAT Gateway which would have considerable cost implications. - */ - constructor(scope: Construct, id: string, props: VercelApiProxyProps) { - super(scope, id); - - const proxyPort = 3128; - - // Create security group for the proxy - const proxySg = new ec2.SecurityGroup(this, "proxy-sg", { - vpc: props.vpc, - description: "Allow inbound from ECS for Squid proxy", - allowAllOutbound: true, - }); - - // Allow ECS (internal) traffic on port 3128 - proxySg.addIngressRule( - ec2.Peer.ipv4(props.vpc.vpcCidrBlock), - ec2.Port.tcp(proxyPort), - "Allow ECS to connect to Squid" - ); - - // Squid install and minimal config - const userData = ec2.UserData.forLinux(); - userData.addCommands( - "yum update -y", - "yum install -y squid", - - // Write squid.conf using heredoc - "cat <<'EOF' > /etc/squid/squid.conf", - `http_port ${proxyPort}`, - "acl all src 0.0.0.0/0", - "http_access allow all", - "EOF", - - // Enable and start Squid - "systemctl enable squid", - "systemctl restart squid" - ); - - // Enable SSM access for the EC2 instance - const ssmRole = new iam.Role(this, "ec2-ssm-role", { - assumedBy: new iam.ServicePrincipal("ec2.amazonaws.com"), - managedPolicies: [ - iam.ManagedPolicy.fromAwsManagedPolicyName( - "AmazonSSMManagedInstanceCore" - ), - ], - }); - - // Launch EC2 instance - const instance = new ec2.Instance(this, "squid-proxy", { - vpc: props.vpc, - role: ssmRole, - instanceType: ec2.InstanceType.of( - ec2.InstanceClass.T3, - ec2.InstanceSize.MICRO - ), - machineImage: ec2.MachineImage.latestAmazonLinux2023(), - vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, - securityGroup: proxySg, - userData, - }); - - // Allocate and associate Elastic IP - const eip = new ec2.CfnEIP(this, "proxy-eip", { - domain: "vpc", - tags: [ - { - key: "Name", - value: `${cdk.Stack.of(this).stackName}-proxy-eip`, - }, - ], - }); - new ec2.CfnEIPAssociation(this, "proxy-eip-assoc", { - allocationId: eip.attrAllocationId, - instanceId: instance.instanceId, - }); - - // Route 53 Private Hosted Zone - const zone = new route53.PrivateHostedZone(this, "proxy-zone", { - vpc: props.vpc, - zoneName: props.proxyDomain, - }); - new route53.ARecord(this, "proxy-a-record", { - zone, - target: route53.RecordTarget.fromIpAddresses(instance.instancePrivateIp), - ttl: cdk.Duration.seconds(60), - }); - - this.url = `http://${props.proxyDomain}:${proxyPort}`; - } -} diff --git a/deploy/package-lock.json b/deploy/package-lock.json deleted file mode 100644 index 788c7e3..0000000 --- a/deploy/package-lock.json +++ /dev/null @@ -1,678 +0,0 @@ -{ - "name": "deploy", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "deploy", - "version": "0.1.0", - "dependencies": { - "aws-cdk-lib": "2.206.0", - "constructs": "^10.0.0" - }, - "bin": { - "deploy": "bin/deploy.js" - }, - "devDependencies": { - "@types/node": "22.7.9", - "aws-cdk": "2.1023.0", - "ts-node": "^10.9.2", - "typescript": "~5.6.3" - } - }, - "node_modules/@aws-cdk/asset-awscli-v1": { - "version": "2.2.242", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-awscli-v1/-/asset-awscli-v1-2.2.242.tgz", - "integrity": "sha512-4c1bAy2ISzcdKXYS1k4HYZsNrgiwbiDzj36ybwFVxEWZXVAP0dimQTCaB9fxu7sWzEjw3d+eaw6Fon+QTfTIpQ==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/asset-node-proxy-agent-v6": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/asset-node-proxy-agent-v6/-/asset-node-proxy-agent-v6-2.1.0.tgz", - "integrity": "sha512-7bY3J8GCVxLupn/kNmpPc5VJz8grx+4RKfnnJiO1LG+uxkZfANZG3RMHhE+qQxxwkyQ9/MfPtTpf748UhR425A==", - "license": "Apache-2.0" - }, - "node_modules/@aws-cdk/cloud-assembly-schema": { - "version": "45.2.0", - "resolved": "https://registry.npmjs.org/@aws-cdk/cloud-assembly-schema/-/cloud-assembly-schema-45.2.0.tgz", - "integrity": "sha512-5TTUkGHQ+nfuUGwKA8/Yraxb+JdNUh4np24qk/VHXmrCMq+M6HfmGWfhcg/QlHA2S5P3YIamfYHdQAB4uSNLAg==", - "bundleDependencies": [ - "jsonschema", - "semver" - ], - "license": "Apache-2.0", - "dependencies": { - "jsonschema": "~1.4.1", - "semver": "^7.7.2" - }, - "engines": { - "node": ">= 18.0.0" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/jsonschema": { - "version": "1.4.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/@aws-cdk/cloud-assembly-schema/node_modules/semver": { - "version": "7.7.2", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.7.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.9.tgz", - "integrity": "sha512-jrTfRC7FM6nChvU7X2KqcrgquofrWLFDeYC1hKfwNWomVvrn7JIksqf344WN2X/y8xrgqBd2dJATZV4GbatBfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.19.2" - } - }, - "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, - "license": "MIT" - }, - "node_modules/aws-cdk": { - "version": "2.1023.0", - "resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1023.0.tgz", - "integrity": "sha512-DWMA+IrAsBUNF2RvH7ujpDp7wSJkqTkRL8yfK4AYpEjoGY1KMaKIfxz3M3+Nk3ogM7VhZiW3OGWEOgyDF47HOQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "cdk": "bin/cdk" - }, - "engines": { - "node": ">= 18.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/aws-cdk-lib": { - "version": "2.206.0", - "resolved": "https://registry.npmjs.org/aws-cdk-lib/-/aws-cdk-lib-2.206.0.tgz", - "integrity": "sha512-WQGSSzSX+CvIG3j4GICxCAARGaB2dbB2ZiAn8dqqWdUkF6G9pedlSd3bjB0NHOqrxJMu3jYQCYf3gLYTaJuR8A==", - "bundleDependencies": [ - "@balena/dockerignore", - "case", - "fs-extra", - "ignore", - "jsonschema", - "minimatch", - "punycode", - "semver", - "table", - "yaml", - "mime-types" - ], - "license": "Apache-2.0", - "dependencies": { - "@aws-cdk/asset-awscli-v1": "2.2.242", - "@aws-cdk/asset-node-proxy-agent-v6": "^2.1.0", - "@aws-cdk/cloud-assembly-schema": "^45.0.0", - "@balena/dockerignore": "^1.0.2", - "case": "1.6.3", - "fs-extra": "^11.3.0", - "ignore": "^5.3.2", - "jsonschema": "^1.5.0", - "mime-types": "^2.1.35", - "minimatch": "^3.1.2", - "punycode": "^2.3.1", - "semver": "^7.7.2", - "table": "^6.9.0", - "yaml": "1.10.2" - }, - "engines": { - "node": ">= 14.15.0" - }, - "peerDependencies": { - "constructs": "^10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/@balena/dockerignore": { - "version": "1.0.2", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/aws-cdk-lib/node_modules/ajv": { - "version": "8.17.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/aws-cdk-lib/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/aws-cdk-lib/node_modules/ansi-styles": { - "version": "4.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aws-cdk-lib/node_modules/astral-regex": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/aws-cdk-lib/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/brace-expansion": { - "version": "1.1.12", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/aws-cdk-lib/node_modules/case": { - "version": "1.6.3", - "inBundle": true, - "license": "(MIT OR GPL-3.0-or-later)", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/color-convert": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/color-name": { - "version": "1.1.4", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/concat-map": { - "version": "0.0.1", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/fast-deep-equal": { - "version": "3.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/fast-uri": { - "version": "3.0.6", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "inBundle": true, - "license": "BSD-3-Clause" - }, - "node_modules/aws-cdk-lib/node_modules/fs-extra": { - "version": "11.3.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/aws-cdk-lib/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/aws-cdk-lib/node_modules/ignore": { - "version": "5.3.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/aws-cdk-lib/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/aws-cdk-lib/node_modules/json-schema-traverse": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/jsonfile": { - "version": "6.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/jsonschema": { - "version": "1.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/lodash.truncate": { - "version": "4.4.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/aws-cdk-lib/node_modules/mime-db": { - "version": "1.52.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/mime-types": { - "version": "2.1.35", - "inBundle": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/aws-cdk-lib/node_modules/minimatch": { - "version": "3.1.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/aws-cdk-lib/node_modules/punycode": { - "version": "2.3.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/aws-cdk-lib/node_modules/require-from-string": { - "version": "2.0.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/semver": { - "version": "7.7.2", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/aws-cdk-lib/node_modules/slice-ansi": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/aws-cdk-lib/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/aws-cdk-lib/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/aws-cdk-lib/node_modules/table": { - "version": "6.9.0", - "inBundle": true, - "license": "BSD-3-Clause", - "dependencies": { - "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", - "slice-ansi": "^4.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/universalify": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/aws-cdk-lib/node_modules/yaml": { - "version": "1.10.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">= 6" - } - }, - "node_modules/constructs": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/constructs/-/constructs-10.4.2.tgz", - "integrity": "sha512-wsNxBlAott2qg8Zv87q3eYZYgheb9lchtBfjHzzLHtXbttwSrHPs1NNQbBrmbb1YZvYg2+Vh0Dor76w4mFxJkA==", - "license": "Apache-2.0" - }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, - "license": "ISC" - }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" - }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } - } - }, - "node_modules/typescript": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", - "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.19.8", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", - "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", - "dev": true, - "license": "MIT" - }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT" - }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - } - } -} diff --git a/deploy/package.json b/deploy/package.json deleted file mode 100644 index 2b902bc..0000000 --- a/deploy/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "deploy", - "version": "0.1.0", - "bin": { - "deploy": "bin/deploy.js" - }, - "scripts": { - "build": "tsc", - "watch": "tsc -w", - "cdk": "cdk" - }, - "devDependencies": { - "@types/node": "22.7.9", - "aws-cdk": "2.1023.0", - "ts-node": "^10.9.2", - "typescript": "~5.6.3" - }, - "dependencies": { - "aws-cdk-lib": "2.206.0", - "constructs": "^10.0.0" - } -} diff --git a/deploy/tsconfig.json b/deploy/tsconfig.json deleted file mode 100644 index 28bb557..0000000 --- a/deploy/tsconfig.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "lib": [ - "es2022" - ], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, - "inlineSources": true, - "experimentalDecorators": true, - "strictPropertyInitialization": false, - "typeRoots": [ - "./node_modules/@types" - ] - }, - "exclude": [ - "node_modules", - "cdk.out" - ] -} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..26e0538 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +services: + # ── MinIO (backing object store) ────────────────────────────────────── + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + ports: + - "9000:9000" # S3 API + - "9001:9001" # Web console + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio-data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + + # ── Seed MinIO with example buckets and data ────────────────────────── + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: /bin/sh + command: + - -c + - | + mc alias set local http://minio:9000 minioadmin minioadmin + mc mb --ignore-existing local/public-data + mc mb --ignore-existing local/private-uploads + mc anonymous set download local/public-data + echo "Hello from s3-proxy!" | mc pipe local/public-data/hello.txt + echo '{"status":"ok"}' | mc pipe local/public-data/health.json + echo "Secret payload" | mc pipe local/private-uploads/docs/secret.txt + +volumes: + minio-data: diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts new file mode 100644 index 0000000..df22c5a --- /dev/null +++ b/docs/.vitepress/config.ts @@ -0,0 +1,167 @@ +import { defineConfig } from "vitepress"; +import { withMermaid } from "vitepress-plugin-mermaid"; + +const adminSidebar = [ + { + text: "Getting Started", + items: [ + { text: "Quick Start", link: "/getting-started/" }, + { + text: "Local Development", + link: "/getting-started/local-development", + }, + ], + }, + { + text: "Configuration", + items: [ + { text: "Overview", link: "/configuration/" }, + { text: "Buckets", link: "/configuration/buckets" }, + { text: "Roles", link: "/configuration/roles" }, + { text: "Credentials", link: "/configuration/credentials" }, + { + text: "Providers", + collapsed: false, + items: [ + { text: "Overview", link: "/configuration/providers/" }, + { + text: "Static File", + link: "/configuration/providers/static-file", + }, + { text: "HTTP API", link: "/configuration/providers/http" }, + { + text: "DynamoDB", + link: "/configuration/providers/dynamodb", + }, + { + text: "PostgreSQL", + link: "/configuration/providers/postgres", + }, + { + text: "Caching", + link: "/configuration/providers/cached", + }, + ], + }, + ], + }, + { + text: "Authentication", + items: [ + { text: "Overview", link: "/auth/" }, + { + text: "Client Auth (OIDC/STS)", + link: "/auth/proxy-auth", + }, + { + text: "Backend Auth", + link: "/auth/backend-auth", + }, + { text: "Sealed Session Tokens", link: "/auth/sealed-tokens" }, + ], + }, + { + text: "Deployment", + items: [ + { text: "Overview", link: "/deployment/" }, + { text: "Server Runtime", link: "/deployment/server" }, + { + text: "Cloudflare Workers", + link: "/deployment/cloudflare-workers", + }, + ], + }, + { + text: "Architecture", + items: [ + { text: "Overview", link: "/architecture/" }, + { text: "Crate Layout", link: "/architecture/crate-layout" }, + { + text: "Request Lifecycle", + link: "/architecture/request-lifecycle", + }, + { + text: "Multi-Runtime Design", + link: "/architecture/multi-runtime", + }, + ], + }, + { + text: "Extending", + items: [ + { text: "Overview", link: "/extending/" }, + { text: "Custom Resolver", link: "/extending/custom-resolver" }, + { text: "Custom Provider", link: "/extending/custom-provider" }, + { text: "Custom Backend", link: "/extending/custom-backend" }, + ], + }, +]; + +export default withMermaid( + defineConfig({ + base: (process.env.VITEPRESS_BASE as `/${string}/` | undefined) ?? "/", + title: "Source Data Proxy", + description: "Multi-runtime S3 gateway proxy in Rust", + + themeConfig: { + nav: [ + { text: "User Guide", link: "/guide/" }, + { text: "Administration", link: "/getting-started/" }, + { text: "Reference", link: "/reference/" }, + ], + + sidebar: { + "/guide/": [ + { + text: "User Guide", + items: [ + { text: "Overview", link: "/guide/" }, + { text: "Endpoints", link: "/guide/endpoints" }, + { text: "Authentication", link: "/guide/authentication" }, + { text: "Client Usage", link: "/guide/client-usage" }, + ], + }, + ], + + "/getting-started/": adminSidebar, + "/configuration/": adminSidebar, + "/auth/": adminSidebar, + "/deployment/": adminSidebar, + "/architecture/": adminSidebar, + "/extending/": adminSidebar, + + "/reference/": [ + { + text: "Reference", + items: [ + { text: "Overview", link: "/reference/" }, + { + text: "Supported Operations", + link: "/reference/operations", + }, + { text: "Error Codes", link: "/reference/errors" }, + { text: "Config Example", link: "/reference/config-example" }, + ], + }, + ], + }, + + socialLinks: [ + { + icon: "github", + link: "https://github.com/source-cooperative/data.source.coop", + }, + ], + + search: { + provider: "local", + }, + + footer: { + message: "Released under the MIT / Apache-2.0 License.", + copyright: + 'A Radiant Earth project. Copyright © 2026 Source Cooperative.', + }, + }, + }), +); diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000..617deeb --- /dev/null +++ b/docs/.vitepress/theme/index.ts @@ -0,0 +1,4 @@ +import DefaultTheme from "vitepress/theme"; +import "./style.css"; + +export default DefaultTheme; diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 0000000..a74bf41 --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,295 @@ +/** + * Source Cooperative theme + * + * Matches docs.source.coop visual identity: + * - Font: IBM Plex Sans + * - Mono: Berkeley Mono (with fallbacks) + * - Light: warm off-white #efebea background, dark teal-gray #2c3233 text + * - Dark: inverted — #2c3233 background, #efebea text + * + * Reference: github.com/source-cooperative/docs.source.coop/blob/main/src/css/custom.css + */ + +/* ------------------------------------------------------------------ */ +/* Typography */ +/* ------------------------------------------------------------------ */ + +@import url("https://fonts.googleapis.com/css2?family=Cascadia+Mono:wght@400;600&family=IBM+Plex+Sans:ital,wght@0,100..700;1,100..700&family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&display=swap"); + +:root { + --vp-font-family-base: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + --vp-font-family-mono: "IBM Plex Mono", SFMono-Regular, "SF Mono", Menlo, + Consolas, "Liberation Mono", monospace; + --vp-font-family-heading: "Cascadia Mono", var(--vp-font-family-mono); +} + +/* ------------------------------------------------------------------ */ +/* Light theme colors */ +/* Warm off-white background, dark teal-gray primary */ +/* ------------------------------------------------------------------ */ + +:root { + /* Primary — dark teal-gray (same as docs.source.coop) */ + --vp-c-brand-1: #2c3233; + --vp-c-brand-2: #303738; + --vp-c-brand-3: #394142; + --vp-c-brand-soft: rgba(44, 50, 51, 0.08); + + /* Page background — warm off-white */ + --vp-c-bg: #efebea; + --vp-c-bg-alt: #e6e1df; + --vp-c-bg-elv: #ffffff; + --vp-c-bg-soft: #e6e1df; + + /* Text */ + --vp-c-text-1: #2c3233; + --vp-c-text-2: rgba(44, 50, 51, 0.78); + --vp-c-text-3: rgba(44, 50, 51, 0.56); + + /* Borders & dividers */ + --vp-c-divider: rgba(44, 50, 51, 0.12); + --vp-c-gutter: rgba(44, 50, 51, 0.06); + --vp-c-border: rgba(44, 50, 51, 0.15); + + /* Note callout — blue */ + --vp-c-note-1: #3b82f6; + --vp-c-note-2: #2563eb; + --vp-c-note-3: #1d4ed8; + --vp-custom-block-note-border: rgba(59, 130, 246, 0.3); + --vp-custom-block-note-bg: rgba(59, 130, 246, 0.08); + --vp-custom-block-note-code-bg: rgba(59, 130, 246, 0.12); + + /* Tip callout — green */ + --vp-c-tip-1: #16a34a; + --vp-c-tip-2: #15803d; + --vp-c-tip-3: #166534; + --vp-c-tip-soft: rgba(22, 163, 74, 0.08); + --vp-custom-block-tip-border: rgba(22, 163, 74, 0.3); + --vp-custom-block-tip-bg: rgba(22, 163, 74, 0.08); + --vp-custom-block-tip-code-bg: rgba(22, 163, 74, 0.12); + + /* Navbar / sidebar */ + --vp-nav-bg-color: #ffffff; + --vp-sidebar-bg-color: #f7f4f3; + + /* Code blocks */ + --vp-code-block-bg: #f6f7f8; + --vp-code-bg: rgba(44, 50, 51, 0.06); +} + +/* ------------------------------------------------------------------ */ +/* Dark theme colors */ +/* Inverted: teal-gray background, warm off-white primary */ +/* ------------------------------------------------------------------ */ + +.dark { + --vp-c-brand-1: #efebea; + --vp-c-brand-2: #dbd1cf; + --vp-c-brand-3: #b29e99; + --vp-c-brand-soft: rgba(239, 235, 234, 0.1); + + /* Note callout — blue */ + --vp-c-note-1: #60a5fa; + --vp-c-note-2: #93bbfd; + --vp-c-note-3: #3b82f6; + --vp-custom-block-note-border: rgba(96, 165, 250, 0.3); + --vp-custom-block-note-bg: rgba(96, 165, 250, 0.1); + --vp-custom-block-note-code-bg: rgba(96, 165, 250, 0.14); + + /* Tip callout — green */ + --vp-c-tip-1: #4ade80; + --vp-c-tip-2: #86efac; + --vp-c-tip-3: #22c55e; + --vp-c-tip-soft: rgba(74, 222, 128, 0.1); + --vp-custom-block-tip-border: rgba(74, 222, 128, 0.3); + --vp-custom-block-tip-bg: rgba(74, 222, 128, 0.1); + --vp-custom-block-tip-code-bg: rgba(74, 222, 128, 0.14); + + /* Page background */ + --vp-c-bg: #2c3233; + --vp-c-bg-alt: #242a2b; + --vp-c-bg-elv: #343b3c; + --vp-c-bg-soft: #343b3c; + + /* Text */ + --vp-c-text-1: #efebea; + --vp-c-text-2: rgba(239, 235, 234, 0.72); + --vp-c-text-3: rgba(239, 235, 234, 0.48); + + /* Borders & dividers */ + --vp-c-divider: rgba(239, 235, 234, 0.12); + --vp-c-gutter: rgba(239, 235, 234, 0.06); + --vp-c-border: rgba(239, 235, 234, 0.15); + + /* Navbar / sidebar */ + --vp-nav-bg-color: #242a2b; + --vp-sidebar-bg-color: #272e2f; + + /* Code blocks */ + --vp-code-block-bg: rgba(255, 255, 255, 0.06); + --vp-code-bg: rgba(255, 255, 255, 0.1); +} + +/* ------------------------------------------------------------------ */ +/* Buttons */ +/* ------------------------------------------------------------------ */ + +:root { + --vp-button-brand-border: transparent; + --vp-button-brand-text: #efebea; + --vp-button-brand-bg: #2c3233; + --vp-button-brand-hover-border: transparent; + --vp-button-brand-hover-text: #efebea; + --vp-button-brand-hover-bg: #394142; + --vp-button-brand-active-border: transparent; + --vp-button-brand-active-text: #efebea; + --vp-button-brand-active-bg: #1f2324; +} + +.dark { + --vp-button-brand-text: #2c3233; + --vp-button-brand-bg: #efebea; + --vp-button-brand-hover-text: #2c3233; + --vp-button-brand-hover-bg: #ffffff; + --vp-button-brand-active-text: #2c3233; + --vp-button-brand-active-bg: #dbd1cf; +} + +/* ------------------------------------------------------------------ */ +/* Home hero */ +/* ------------------------------------------------------------------ */ + +:root { + --vp-home-hero-name-color: #2c3233; + --vp-home-hero-name-background: none; + --vp-home-hero-image-background-image: none; + --vp-home-hero-image-filter: none; +} + +.dark { + --vp-home-hero-name-color: #efebea; +} + +/* ------------------------------------------------------------------ */ +/* Headings — Cascadia Mono for a distinctive technical feel */ +/* ------------------------------------------------------------------ */ + +.vp-doc h1, +.vp-doc h2, +.vp-doc h3, +.vp-doc h4, +.vp-doc h5, +.vp-doc h6 { + font-family: var(--vp-font-family-heading); + letter-spacing: -0.02em; +} + +/* Hero heading on homepage */ +.VPHero .name { + font-family: var(--vp-font-family-heading) !important; + letter-spacing: -0.03em; +} + +/* Hero text — subtitle size (matches default tagline sizing) */ +.VPHero .text { + font-family: var(--vp-font-family-base) !important; + font-size: 18px !important; + line-height: 28px !important; + font-weight: 500 !important; + letter-spacing: normal !important; + color: var(--vp-c-text-2); +} + +@media (min-width: 640px) { + .VPHero .text { + font-size: 20px !important; + line-height: 32px !important; + } +} + +@media (min-width: 960px) { + .VPHero .text { + font-size: 24px !important; + line-height: 36px !important; + } +} + +/* Hero tagline — uppercase label */ +.VPHero .tagline { + text-transform: uppercase; + font-size: 13px !important; + line-height: 20px !important; + font-weight: 500 !important; + letter-spacing: 0.05em !important; + color: var(--vp-c-text-3) !important; +} + +@media (min-width: 640px) { + .VPHero .tagline { + font-size: 14px !important; + line-height: 22px !important; + } +} + +@media (min-width: 960px) { + .VPHero .tagline { + font-size: 14px !important; + line-height: 22px !important; + } +} + +/* Feature card titles */ +.VPFeature .title { + font-family: var(--vp-font-family-heading); +} + +/* Sidebar group headings */ +.VPSidebarItem.level-0 > .item > .text { + font-family: var(--vp-font-family-heading); +} + +/* ------------------------------------------------------------------ */ +/* Content links — underlined, matching docs.source.coop */ +/* ------------------------------------------------------------------ */ + +.vp-doc a { + text-decoration: underline; + text-underline-offset: 2px; +} + +.vp-doc a:hover { + text-decoration: none; +} + +/* ------------------------------------------------------------------ */ +/* Navbar — clean white surface (light) / dark surface (dark) */ +/* ------------------------------------------------------------------ */ + +.VPNav { + background-color: var(--vp-nav-bg-color) !important; +} + +.VPNavBar { + background-color: var(--vp-nav-bg-color) !important; + border-bottom: 1px solid var(--vp-c-divider) !important; +} + +.VPNavBar .divider { + display: none; +} + +/* ------------------------------------------------------------------ */ +/* Sidebar — subtle background */ +/* ------------------------------------------------------------------ */ + +.VPSidebar { + background-color: var(--vp-sidebar-bg-color) !important; +} + +/* ------------------------------------------------------------------ */ +/* Code font size — match docs.source.coop 95% */ +/* ------------------------------------------------------------------ */ + +:root { + --vp-code-font-size: 95%; +} diff --git a/docs/architecture/crate-layout.md b/docs/architecture/crate-layout.md new file mode 100644 index 0000000..8b4aa69 --- /dev/null +++ b/docs/architecture/crate-layout.md @@ -0,0 +1,113 @@ +# Crate Layout + +The project is organized as a Cargo workspace with libraries (traits and logic) and runtimes (executable targets). + +``` +crates/ +├── cli/ # source-coop CLI (OIDC login → STS credential exchange) +├── libs/ # Libraries — not directly runnable +│ ├── core/ (source-coop-core) # Runtime-agnostic: traits, S3 parsing, SigV4, config +│ ├── sts/ (source-coop-sts) # OIDC/STS token exchange (AssumeRoleWithWebIdentity) +│ ├── oidc-provider/ # Outbound OIDC provider (JWT signing, JWKS, exchange) +│ └── source-coop/ # Source Cooperative resolver and API client +└── runtimes/ # Runnable targets — one per deployment platform + ├── server/ (source-coop-server) # Tokio/Hyper for container deployments + └── cf-workers/ # Cloudflare Workers for edge deployments +``` + +## Crate Responsibilities + +### `source-coop-core` + +The runtime-agnostic core. Contains: +- `ProxyHandler` — Two-phase request handler (`resolve_request()` → `HandlerAction`) +- `RequestResolver` and `DefaultResolver` — Request parsing, SigV4 auth, authorization +- `ConfigProvider` trait and implementations (static file, HTTP, DynamoDB, Postgres) +- `ProxyBackend` trait — Runtime abstraction for store/signer/raw HTTP +- S3 request parsing, XML response building, list prefix rewriting +- SigV4 signature verification +- Sealed session token encryption/decryption +- Type definitions (`BucketConfig`, `RoleConfig`, `AccessScope`, etc.) + +**Feature flags:** +- `config-http` — HTTP API config provider +- `config-dynamodb` — DynamoDB config provider +- `config-postgres` — PostgreSQL config provider +- `azure` — Azure Blob Storage support +- `gcp` — Google Cloud Storage support + +### `source-coop-sts` + +OIDC token exchange implementing `AssumeRoleWithWebIdentity`: +- JWT decoding and validation (RS256) +- JWKS fetching and caching +- Trust policy evaluation (issuer, audience, subject conditions) +- Temporary credential minting with scope template variables + +### `source-coop-oidc-provider` + +Outbound OIDC identity provider for backend authentication: +- RSA JWT signing (`JwtSigner`) +- JWKS endpoint serving +- OpenID Connect discovery document +- AWS credential exchange (`AwsOidcBackendAuth`) +- Credential caching + +### `source-coop-server` + +The native server runtime: +- Tokio/Hyper HTTP server +- `ServerBackend` implementing `ProxyBackend` with reqwest +- Streaming via hyper `Incoming` bodies and reqwest `bytes_stream()` +- CLI argument parsing (`--config`, `--listen`, `--domain`, `--sts-config`) + +### `source-coop-cf-workers` + +The Cloudflare Workers WASM runtime: +- `WorkerBackend` implementing `ProxyBackend` with `web_sys::fetch` +- `FetchConnector` bridging `object_store` HTTP to Workers Fetch API +- JS `ReadableStream` passthrough for zero-copy streaming +- Config loading from env vars (`PROXY_CONFIG`) + +> [!WARNING] +> This crate is excluded from the workspace `default-members` because WASM types are `!Send` and won't compile on native targets. Always build with `--target wasm32-unknown-unknown`. + +### `source-coop` (lib) + +Source Cooperative-specific resolver and API client: +- `SourceCoopResolver` — Custom namespace mapping (`/{account}/{repo}/{key}`) +- External auth via Source Cooperative API + +### `cli` + +Command-line tool for OIDC authentication: +- Browser-based OAuth2 Authorization Code + PKCE flow +- `credential_process` integration with AWS SDKs +- Credential caching in OS keyring (with file fallback) + +## Dependency Flow + +```mermaid +flowchart TD + core["source-coop-core"] + sts["source-coop-sts"] + oidc["source-coop-oidc-provider"] + api["source-coop (lib)"] + server["source-coop-server"] + workers["source-coop-cf-workers"] + cli["source-coop CLI"] + + server --> core + server --> sts + server --> oidc + workers --> core + workers --> sts + workers --> oidc + workers --> api + cli --> sts + sts --> core + oidc --> core + api --> core +``` + +Libraries define trait abstractions. Runtimes implement `ProxyBackend` with platform-native primitives and wire everything together. diff --git a/docs/architecture/index.md b/docs/architecture/index.md new file mode 100644 index 0000000..652ccb0 --- /dev/null +++ b/docs/architecture/index.md @@ -0,0 +1,57 @@ +# Architecture Overview + +The Source Data Proxy is an S3-compliant gateway that sits between clients and backend object stores. It provides authentication, authorization, and transparent proxying with zero-copy streaming. + +## High-Level Architecture + +```mermaid +flowchart LR + Clients["S3 Clients
(aws-cli, boto3, SDKs)"] + + subgraph Proxy["source-coop-proxy"] + Resolver["Request Resolver
(parse, auth, authorize)"] + Handler["Proxy Handler
(dispatch operations)"] + Backend["Proxy Backend
(runtime-specific I/O)"] + end + + Config["Config Provider
(Static, HTTP, DynamoDB, Postgres)"] + OIDC["OIDC Providers
(Auth0, GitHub, Keycloak)"] + Stores["Object Stores
(S3, MinIO, R2, Azure, GCS)"] + + Clients <--> Resolver + Resolver <--> Config + Resolver <--> OIDC + Handler <--> Backend + Backend <--> Stores +``` + +## Design Principles + +**Runtime-agnostic core** — The core proxy logic (`source-coop-core`) has zero runtime dependencies. No Tokio, no `worker-rs`. It compiles to both native and WASM targets. + +**Two-phase handler** — The proxy handler separates request resolution from execution. `resolve_request()` determines what to do; the runtime executes it. This keeps streaming logic in runtime-specific code where it belongs. + +**Presigned URLs for streaming** — GET, HEAD, PUT, and DELETE operations use presigned URLs. The runtime forwards the request directly to the backend — no buffering, no double-handling of bodies. + +**Pluggable traits** — Three trait boundaries enable customization: +- `RequestResolver` — How requests are parsed, authenticated, and authorized +- `ConfigProvider` — Where configuration comes from +- `ProxyBackend` — How the runtime interacts with backends + +## Key Components + +| Component | Crate | Responsibility | +|-----------|-------|---------------| +| [Proxy Handler](./request-lifecycle) | `core` | Dispatch operations via presigned URLs, LIST, or multipart | +| [Request Resolver](./request-lifecycle#request-resolution) | `core` | Parse S3 requests, authenticate, authorize | +| [Config Providers](/configuration/providers/) | `core` | Load buckets, roles, credentials | +| [STS Handler](/auth/proxy-auth#oidcsts-temporary-credentials) | `sts` | OIDC token exchange, credential minting | +| [OIDC Provider](/auth/backend-auth#oidc-backend-auth) | `oidc-provider` | Self-signed JWT minting, backend credential exchange | +| [Server Runtime](./multi-runtime#server-runtime) | `server` | Tokio/Hyper HTTP server | +| [Workers Runtime](./multi-runtime#cloudflare-workers-runtime) | `cf-workers` | WASM-based Cloudflare Workers | + +## Further Reading + +- [Crate Layout](./crate-layout) — How the workspace is organized +- [Request Lifecycle](./request-lifecycle) — How a request flows through the proxy +- [Multi-Runtime Design](./multi-runtime) — How the same core runs on native and WASM diff --git a/docs/architecture/multi-runtime.md b/docs/architecture/multi-runtime.md new file mode 100644 index 0000000..94e6912 --- /dev/null +++ b/docs/architecture/multi-runtime.md @@ -0,0 +1,78 @@ +# Multi-Runtime Design + +The proxy runs on two runtimes — a native Tokio/Hyper server for container deployments and Cloudflare Workers for edge deployments. The same core logic compiles to both targets through careful abstraction of platform-specific concerns. + +## Runtime Comparison + +| | Server Runtime | CF Workers Runtime | +|---|---|---| +| **Platform** | Linux/macOS containers | Cloudflare Workers (V8) | +| **Target** | `x86_64` / `aarch64` | `wasm32-unknown-unknown` | +| **HTTP client** | reqwest | `web_sys::fetch` | +| **Streaming** | hyper `Incoming` / reqwest `bytes_stream()` | JS `ReadableStream` passthrough | +| **Object store connector** | Default (reqwest-based) | `FetchConnector` | +| **Backend support** | S3, Azure, GCS | S3 only | +| **Config loading** | TOML file | Env var (JSON or JS object) | +| **Threading** | Multi-threaded (`Send + Sync` required) | Single-threaded (`!Send` types allowed) | + +## How It Works + +### MaybeSend / MaybeSync + +The core challenge is that Tokio requires `Send + Sync` for task spawning, while WASM runtimes are single-threaded and use `!Send` types (like `JsValue` and `ReadableStream`). + +The solution is conditional trait aliases defined in `source-coop-core`: + +- On native targets: `MaybeSend` resolves to `Send`, `MaybeSync` resolves to `Sync` +- On `wasm32`: `MaybeSend` and `MaybeSync` are blanket traits that every type implements + +All core traits (`ProxyBackend`, `RequestResolver`, `ConfigProvider`) use `MaybeSend + MaybeSync` instead of `Send + Sync`, so they compile on both targets. + +The `Signer` trait from `object_store` requires real `Send + Sync`, which works because `UnsignedUrlSigner` only holds `String` fields, and `object_store`'s built-in store types are `Send + Sync`. + +### RPITIT Async Methods + +Core traits use return-position `impl Trait` in trait (RPITIT) for async methods instead of `#[async_trait]`: + +```rust +pub trait RequestResolver: Clone + MaybeSend + MaybeSync + 'static { + fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> impl Future> + MaybeSend; +} +``` + +This avoids `#[async_trait]`'s `Box` requirement, which won't compile on WASM targets. + +## Server Runtime + +The server runtime (`crates/runtimes/server/`) uses Tokio and Hyper: + +- **Forward actions**: reqwest sends the presigned URL request. For GET, the response body is streamed via `bytes_stream()`. For PUT, the client's hyper `Incoming` body is streamed directly to reqwest. +- **`ServerBackend`**: Creates `object_store` instances with the default HTTP connector (reqwest) and uses reqwest for `send_raw()` (multipart). + +## Cloudflare Workers Runtime + +The CF Workers runtime (`crates/runtimes/cf-workers/`) uses `worker-rs`, `wasm-bindgen`, and `web_sys`: + +- **Forward actions**: JS `ReadableStream` bodies pass through without touching Rust. The Workers Fetch API handles streaming natively. +- **`WorkerBackend`**: Creates `object_store` instances with `FetchConnector` injected for HTTP transport. + +### FetchConnector + +`FetchConnector` bridges `object_store`'s `HttpConnector` trait to the Workers Fetch API. Since `worker::Fetch::send()` is `!Send`, each call is wrapped in `spawn_local` with a oneshot channel to bridge back to the `Send` context that `object_store` expects. + +This is only used for LIST operations — presigned URL operations bypass `object_store` entirely. + +### WASM Limitations + +- **S3 only**: Azure and GCS builders are gated behind cargo features that are disabled for the Workers runtime +- **`Instant::now()` panics on WASM**: The `UnsignedUrlSigner` avoids the `InstanceCredentialProvider` → `TokenCache` → `Instant::now()` code path that panics on WASM +- **No `default-members`**: The CF Workers crate is excluded from the workspace default members. Always build with: + ```bash + cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + ``` diff --git a/docs/architecture/request-lifecycle.md b/docs/architecture/request-lifecycle.md new file mode 100644 index 0000000..4bea162 --- /dev/null +++ b/docs/architecture/request-lifecycle.md @@ -0,0 +1,93 @@ +# Request Lifecycle + +Every S3 request flows through a two-phase dispatch model: first the request is resolved (parsed, authenticated, authorized), then the appropriate action is executed by the runtime. + +## Overview + +```mermaid +sequenceDiagram + participant Client + participant Runtime as Runtime
(Server or Workers) + participant Resolver as Request Resolver + participant Handler as Proxy Handler + participant Backend as Backend Store + + Client->>Runtime: HTTP request + Runtime->>Handler: resolve_request(method, path, query, headers) + Handler->>Resolver: resolve(method, path, query, headers) + Resolver->>Resolver: Parse S3 operation + Resolver->>Resolver: Authenticate (SigV4) + Resolver->>Resolver: Authorize (check scopes) + Resolver-->>Handler: ResolvedAction::Proxy + Handler->>Handler: Dispatch operation + Handler-->>Runtime: HandlerAction + + alt Forward (GET/HEAD/PUT/DELETE) + Runtime->>Backend: Execute presigned URL + Backend-->>Runtime: Stream response + Runtime-->>Client: Stream response + else Response (LIST, errors) + Runtime-->>Client: Return response body + else NeedsBody (multipart) + Runtime->>Runtime: Collect request body + Runtime->>Handler: handle_with_body(pending, body) + Handler->>Backend: Signed multipart request + Backend-->>Handler: Response + Handler-->>Runtime: ProxyResult + Runtime-->>Client: Response + end +``` + +## Phase 1: Request Resolution + +The `RequestResolver` determines what to do with an incoming request. The `DefaultResolver` handles standard S3 proxy behavior: + +1. **Parse the S3 operation** from the HTTP method, path, query, and headers + - Path-style: `GET /bucket/key` → GetObject on `bucket` with key `key` + - Virtual-hosted: `GET /key` with `Host: bucket.s3.example.com` → same operation +2. **Authenticate** the request by verifying the SigV4 signature against stored or sealed credentials +3. **Authorize** by checking the caller's access scopes against the requested bucket, key prefix, and operation +4. **Return** a `ResolvedAction`: + - `Proxy { operation, bucket_config, list_rewrite }` — forward to a backend + - `Response { status, headers, body }` — return a synthetic response (e.g., `ListBuckets`) + +Custom resolvers can implement entirely different routing, authentication, and namespace mapping. + +## Phase 2: Handler Dispatch + +The `ProxyHandler` takes the resolved action and dispatches it based on the S3 operation type. It returns a `HandlerAction` enum: + +### `Forward(ForwardRequest)` + +Used for: **GET, HEAD, PUT, DELETE** + +The handler generates a presigned URL using the backend's `Signer` and returns it to the runtime with filtered headers. The runtime executes the presigned URL with its native HTTP client, streaming request and response bodies directly. The handler never touches the body data. + +- Presigned URL TTL: 300 seconds +- Headers forwarded: `range`, `if-match`, `if-none-match`, `if-modified-since`, `if-unmodified-since`, `content-type`, `content-length`, `content-md5`, `content-encoding`, `content-disposition`, `cache-control`, `x-amz-content-sha256` + +### `Response(ProxyResult)` + +Used for: **LIST, errors, synthetic responses** + +For LIST operations, the handler calls `object_store::list_with_delimiter()` via the backend's store, builds S3 `ListObjectsV2` XML from the results, and returns it as a complete response. If a `ListRewrite` is configured, key prefixes are transformed in the XML. + +> [!NOTE] +> LIST returns all results in a single response. `IsTruncated` is always `false`. The proxy does not support S3-style pagination with continuation tokens. + +### `NeedsBody(PendingRequest)` + +Used for: **CreateMultipartUpload, UploadPart, CompleteMultipartUpload, AbortMultipartUpload** + +Multipart operations need the request body (e.g., the XML body for `CompleteMultipartUpload`). The runtime materializes the body, then calls `handler.handle_with_body()`, which signs the request using `S3RequestSigner` and sends it via `backend.send_raw()`. + +> [!WARNING] +> Multipart uploads are only supported for `backend_type = "s3"`. Non-S3 backends should use single PUT requests (object_store handles chunking internally). + +## Response Header Forwarding + +The proxy forwards only specific headers from the backend response to the client: + +`content-type`, `content-length`, `content-range`, `etag`, `last-modified`, `accept-ranges`, `content-encoding`, `content-disposition`, `cache-control`, `x-amz-request-id`, `x-amz-version-id`, `location` + +All other backend headers are filtered out. diff --git a/docs/auth/backend-auth.md b/docs/auth/backend-auth.md new file mode 100644 index 0000000..1ef0dbe --- /dev/null +++ b/docs/auth/backend-auth.md @@ -0,0 +1,251 @@ +# Authenticating with Object Store Backends + +The proxy needs credentials to access backend object stores (S3, Azure Blob Storage, GCS). There are two approaches: static credentials stored in the proxy config, and OIDC-based credential resolution where the proxy acts as its own identity provider. + +## Static Backend Credentials + +The simplest approach is to include credentials directly in the bucket's `backend_options`: + +```toml +[[buckets]] +name = "my-data" +backend_type = "s3" + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" +access_key_id = "AKIAIOSFODNN7EXAMPLE" +secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +``` + +This works for any backend type. For anonymous backend access (e.g., public buckets), omit the credential fields and add `skip_signature = "true"`. + +## OIDC Backend Auth + +For production deployments, the proxy can act as its own OIDC identity provider. Instead of storing long-lived backend credentials, the proxy mints self-signed JWTs and exchanges them with cloud providers for temporary credentials — the same pattern used by GitHub Actions and Vercel for AWS access. + +### How It Works + +```mermaid +sequenceDiagram + participant Client as S3 Client + participant Proxy as Data Proxy + participant Cloud as Cloud Provider STS
(e.g., AWS STS) + participant Store as Object Store
(e.g., S3) + + Client->>Proxy: 1. S3 request (e.g., GET /bucket/key) + Proxy->>Proxy: 2. Authenticate client + Proxy->>Proxy: 3. Bucket has auth_type=oidc
Mint self-signed JWT + Proxy->>Cloud: 4. AssumeRoleWithWebIdentity
(JWT + Role ARN) + Cloud->>Proxy: 5. Fetch /.well-known/jwks.json + Proxy-->>Cloud: 6. RSA public key + Cloud-->>Proxy: 7. Temporary credentials + Proxy->>Proxy: 8. Cache credentials + Proxy->>Store: 9. Forward request with
temporary credentials + Store-->>Proxy: 10. Response + Proxy-->>Client: 11. Response +``` + +### Configuration + +OIDC backend auth requires two environment variables: + +| Variable | Description | +|----------|-------------| +| `OIDC_PROVIDER_KEY` | PEM-encoded RSA private key for JWT signing | +| `OIDC_PROVIDER_ISSUER` | Publicly reachable URL (e.g., `https://data.source.coop`) | + +Generate an RSA key pair: + +```bash +openssl genrsa -out oidc-key.pem 2048 +``` + +Set the environment variables: + +```bash +export OIDC_PROVIDER_KEY=$(cat oidc-key.pem) +export OIDC_PROVIDER_ISSUER="https://data.source.coop" +``` + +Then configure buckets to use OIDC: + +```toml +[[buckets]] +name = "my-data" +backend_type = "s3" + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" +auth_type = "oidc" +oidc_role_arn = "arn:aws:iam::123456789012:role/DataProxyAccess" +``` + +### Discovery Endpoints + +When OIDC provider keys are configured, the proxy serves two well-known endpoints that cloud providers use to validate JWTs: + +**`GET /.well-known/openid-configuration`** +```json +{ + "issuer": "https://data.source.coop", + "jwks_uri": "https://data.source.coop/.well-known/jwks.json", + "response_types_supported": ["id_token"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] +} +``` + +**`GET /.well-known/jwks.json`** +```json +{ + "keys": [{ + "kty": "RSA", + "alg": "RS256", + "use": "sig", + "kid": "proxy-key-1", + "n": "", + "e": "" + }] +} +``` + +> [!WARNING] +> These endpoints must be publicly accessible. Cloud providers fetch them at JWT validation time to verify signatures. If they are behind a firewall or VPN, credential exchange will fail. + +### The Exchange Flow in Detail + +When a request arrives for a bucket with `auth_type=oidc`: + +1. The `OidcBackendAuth` handler detects `auth_type=oidc` in the bucket's `backend_options` +2. It mints a short-lived JWT signed with the proxy's RSA private key: + - `iss`: the configured `OIDC_PROVIDER_ISSUER` + - `sub`: a connection identifier (from `oidc_subject` option, or a default) + - `aud`: the cloud provider's STS audience (e.g., `sts.amazonaws.com`) + - `exp`: short expiration (minutes) +3. The proxy sends the JWT to the cloud provider's STS endpoint along with the target IAM role ARN +4. The cloud provider fetches the proxy's JWKS, verifies the JWT signature, evaluates the role's trust policy, and returns temporary credentials +5. The proxy caches the credentials (keyed by role ARN) and injects them into the bucket config +6. The existing `build_object_store()` / `build_signer()` pipeline consumes the credentials normally + +On subsequent requests, cached credentials are reused until they expire. + +## Cloud Provider Setup + +### AWS S3 + +**Administrator setup:** + +1. **Register the OIDC provider** in your AWS account: + ```bash + aws iam create-open-id-connect-provider \ + --url https://data.source.coop \ + --client-id-list sts.amazonaws.com \ + --thumbprint-list + ``` + + > [!TIP] + > To get the thumbprint, fetch the TLS certificate chain from your proxy's domain. AWS uses this to verify the HTTPS connection to the JWKS endpoint. + +2. **Create an IAM Role** with a trust policy that allows the proxy to assume it: + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": { + "Federated": "arn:aws:iam::123456789012:oidc-provider/data.source.coop" + }, + "Action": "sts:AssumeRoleWithWebIdentity", + "Condition": { + "StringEquals": { + "data.source.coop:aud": "sts.amazonaws.com", + "data.source.coop:sub": "s3-proxy" + } + } + }] + } + ``` + +3. **Attach an S3 permission policy** to the role: + ```json + { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": [ + "s3:GetObject", + "s3:PutObject", + "s3:ListBucket", + "s3:DeleteObject" + ], + "Resource": [ + "arn:aws:s3:::my-backend-bucket", + "arn:aws:s3:::my-backend-bucket/*" + ] + }] + } + ``` + +4. **Configure the bucket** in the proxy: + ```toml + [[buckets]] + name = "my-data" + backend_type = "s3" + + [buckets.backend_options] + endpoint = "https://s3.us-east-1.amazonaws.com" + bucket_name = "my-backend-bucket" + region = "us-east-1" + auth_type = "oidc" + oidc_role_arn = "arn:aws:iam::123456789012:role/DataProxyAccess" + ``` + +**At request time**, the proxy calls AWS STS `AssumeRoleWithWebIdentity` with the self-signed JWT. No AWS credentials are stored in the proxy configuration. + +### Azure Blob Storage + +> [!NOTE] +> **Planned** — Azure OIDC backend auth is planned but not yet implemented. The proxy currently supports Azure with static credentials only. + +**Planned setup:** + +1. Create an App Registration in Microsoft Entra ID +2. Add a Federated Identity Credential specifying the proxy's issuer URL and expected `sub` claim +3. Grant the app `Storage Blob Data Contributor` on the target storage account +4. The proxy would exchange its JWT for an Azure AD token via `client_credentials` grant with `jwt-bearer` assertion + +### Google Cloud Storage + +> [!NOTE] +> **Planned** — GCS OIDC backend auth is planned but not yet implemented. The proxy currently supports GCS with static credentials only. + +**Planned setup:** + +1. Create a Workload Identity Pool and OIDC Provider, specifying the proxy's issuer URL +2. Map the external identity to a GCP Service Account +3. Grant the service account GCS permissions +4. The proxy would use a two-step exchange: GCP STS token exchange, then `generateAccessToken` to impersonate the service account + +## Credential Caching + +When using OIDC backend auth, the proxy caches temporary credentials to avoid calling the cloud provider's STS on every request. Credentials are: + +- Keyed by the IAM role ARN +- Automatically refreshed when they expire +- Shared across concurrent requests to the same bucket + +This means the first request to an OIDC-backed bucket incurs a small latency cost for the credential exchange, but subsequent requests use cached credentials until they expire. + +## Choosing Between Static and OIDC + +| | Static Credentials | OIDC Backend Auth | +|---|---|---| +| **Setup complexity** | Low | Medium (IAM role + OIDC provider registration) | +| **Credential rotation** | Manual | Automatic (temporary credentials) | +| **Security** | Long-lived secrets in config | No long-lived secrets | +| **Cloud providers** | All (S3, Azure, GCS) | AWS S3 (Azure and GCS planned) | +| **Latency** | None | Small cost on first request (then cached) | diff --git a/docs/auth/index.md b/docs/auth/index.md new file mode 100644 index 0000000..213ed78 --- /dev/null +++ b/docs/auth/index.md @@ -0,0 +1,46 @@ +# Authentication + +The Source Data Proxy has two distinct authentication concerns: + +1. **Client authentication** — How clients prove their identity to the proxy +2. **Backend authentication** — How the proxy authenticates with backend object stores + +```mermaid +flowchart LR + Client["S3 Client"] + Proxy["Data Proxy"] + Backend["Object Store
(S3, Azure, GCS)"] + + Client -- "SigV4, STS/OIDC" --> Proxy + Proxy -- "Presigned URLs,
OIDC Exchange,
or Static Credentials" --> Backend +``` + +## Client Authentication + +Clients authenticate with the proxy using one of three methods: + +| Method | Use Case | How It Works | +|--------|----------|--------------| +| **Anonymous** | Public datasets | No credentials needed for GET/HEAD/LIST | +| **Long-lived access keys** | Service accounts, internal tools | Static `AccessKeyId`/`SecretAccessKey` with SigV4 signing | +| **OIDC/STS temporary credentials** | CI/CD, user sessions, federated identity | Exchange a JWT from an OIDC provider for scoped temporary credentials | + +The proxy verifies all signed requests using standard AWS Signature Version 4 (SigV4). Any S3-compatible client works without modification — just set the endpoint URL. + +The OIDC/STS flow is the recommended approach for most use cases. See [Client Auth Setup](./proxy-auth) for configuration details. + +## Backend Authentication + +The proxy authenticates with backend object stores using one of two methods: + +| Method | Use Case | How It Works | +|--------|----------|--------------| +| **Static credentials** | Simple setups | `access_key_id`/`secret_access_key` stored in the proxy config | +| **OIDC backend auth** | Production, credential-free | Proxy acts as its own OIDC provider, exchanges self-signed JWTs for cloud credentials | + +OIDC backend auth eliminates the need to store long-lived backend credentials. See [Backend Auth](./backend-auth) for details. + +## Related Topics + +- [Sealed Session Tokens](./sealed-tokens) — How temporary credentials are encrypted for stateless runtimes +- [User Guide: Authentication](/guide/authentication) — User-facing guide for obtaining credentials and using the CLI diff --git a/docs/auth/proxy-auth.md b/docs/auth/proxy-auth.md new file mode 100644 index 0000000..d99baf6 --- /dev/null +++ b/docs/auth/proxy-auth.md @@ -0,0 +1,354 @@ +# Client Authentication Setup + +This page covers how to configure the proxy to authenticate incoming client requests. For the user-facing guide on obtaining credentials and using the CLI, see the [User Guide: Authentication](/guide/authentication). + +## Authentication Modes + +The proxy supports three authentication modes: + +| Mode | Config | Use Case | +|------|--------|----------| +| **Anonymous** | `anonymous_access = true` on a bucket | Public datasets, open data | +| **Long-lived access keys** | `[[credentials]]` entries | Service accounts, internal tools | +| **OIDC/STS temporary credentials** | `[[roles]]` with trust policies | CI/CD, user sessions, federated identity | + +## Anonymous Access + +Enable per-bucket: + +```toml +[[buckets]] +name = "public-data" +backend_type = "s3" +anonymous_access = true +``` + +> [!NOTE] +> Anonymous access only allows `GetObject`, `HeadObject`, and `ListBucket`. Write operations always require authentication. + +## Long-Lived Access Keys + +Static credentials are defined in the config. Each has an access key pair and scoped permissions: + +```toml +[[credentials]] +access_key_id = "AKPROXY00000EXAMPLE" +secret_access_key = "proxy/secret/key/EXAMPLE000000000000" +principal_name = "internal-dashboard" +created_at = "2024-01-15T00:00:00Z" +enabled = true + +[[credentials.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/production/"] +actions = ["get_object", "head_object"] +``` + +Clients sign requests using standard AWS SigV4. Any S3-compatible client works without modification. + +## OIDC/STS Temporary Credentials + +This is the recommended authentication method. Clients exchange a JWT from an OIDC-compatible identity provider for scoped, time-limited credentials via `AssumeRoleWithWebIdentity`. + +### How It Works + +```mermaid +sequenceDiagram + participant Client + participant OIDC as OIDC Provider
(Auth0, GitHub, etc.) + participant Proxy as Data Proxy + participant JWKS as Provider JWKS + + Client->>OIDC: 1. Authenticate + OIDC-->>Client: 2. JWT (id_token) + Client->>Proxy: 3. AssumeRoleWithWebIdentity
(JWT + RoleArn) + Proxy->>JWKS: 4. Fetch JWKS (cached) + JWKS-->>Proxy: 5. Public keys + Proxy->>Proxy: 6. Verify JWT signature (RS256) + Proxy->>Proxy: 7. Check trust policy
(issuer, audience, subject) + Proxy->>Proxy: 8. Mint temporary credentials
(seal into session token) + Proxy-->>Client: 9. AccessKeyId + SecretAccessKey
+ SessionToken + Expiration + Client->>Proxy: 10. S3 request with SigV4
(using temporary credentials) +``` + +### Verification Flow + +When a client calls `AssumeRoleWithWebIdentity`: + +1. The proxy decodes the JWT header to extract the `iss` (issuer) and `kid` (key ID) +2. The proxy verifies the issuer is trusted by the requested role +3. The proxy fetches the issuer's JWKS endpoint and verifies the JWT signature (RS256) +4. The proxy evaluates the trust policy: + - **Issuer**: must be in the role's `trusted_oidc_issuers` + - **Audience**: if `required_audience` is set on the role, the token's `aud` claim must match + - **Subject**: the token's `sub` claim must match at least one of the role's `subject_conditions` (supports `*` glob wildcards) +5. The proxy mints temporary credentials scoped to the role's `allowed_scopes` +6. If `SESSION_TOKEN_KEY` is configured, the credentials are AES-256-GCM encrypted into the session token (see [Sealed Session Tokens](./sealed-tokens)) +7. The proxy returns the credentials in an XML response matching the AWS STS format + +### STS Request Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `Action` | Yes | Must be `AssumeRoleWithWebIdentity` | +| `RoleArn` | Yes | The `role_id` of the role to assume | +| `WebIdentityToken` | Yes | The JWT from the OIDC provider | +| `DurationSeconds` | No | Session duration (900s minimum, capped by `max_session_duration_secs`) | + +### STS Response + +The response follows the standard AWS STS XML format: + +```xml + + + + STSPRXY... + ... + ... + 2024-01-15T01:00:00Z + + + github-actions-deployer/alice + github-actions-deployer + + + +``` + +## Integrating with OIDC Providers + +The proxy works with any OIDC-compliant identity provider that serves a JWKS endpoint and issues RS256-signed JWTs. You need: + +1. The provider's issuer URL (must serve `/.well-known/openid-configuration` with a `jwks_uri`) +2. The `sub` claim format for configuring `subject_conditions` +3. Optionally, the audience claim value for `required_audience` + +

+GitHub Actions — OIDC tokens for CI/CD workflows + +#### Role Configuration + +```toml +[[roles]] +role_id = "github-actions-deployer" +name = "GitHub Actions Deploy Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audience = "sts.s3proxy.example.com" +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", + "repo:myorg/myapp:ref:refs/heads/release/*", +] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] +actions = ["get_object", "head_object", "put_object"] +``` + +#### Workflow Example + +```yaml +jobs: + deploy: + permissions: + id-token: write # Required for OIDC token + steps: + - name: Get OIDC token + id: oidc + run: | + TOKEN=$(curl -s \ + -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.s3proxy.example.com" \ + | jq -r '.value') + echo "token=$TOKEN" >> $GITHUB_OUTPUT + + - name: Assume role via STS + run: | + CREDS=$(aws sts assume-role-with-web-identity \ + --role-arn github-actions-deployer \ + --web-identity-token ${{ steps.oidc.outputs.token }} \ + --endpoint-url https://s3proxy.example.com \ + --output json) + + echo "AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId')" >> $GITHUB_ENV + echo "AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey')" >> $GITHUB_ENV + echo "AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken')" >> $GITHUB_ENV + + - name: Upload to proxy + run: | + aws s3 cp ./bundle.tar.gz s3://deploy-bundles/releases/v1.2.3.tar.gz \ + --endpoint-url https://s3proxy.example.com +``` + +#### Key Details + +- **Issuer URL**: `https://token.actions.githubusercontent.com` +- **Subject format**: `repo:{owner}/{repo}:ref:{ref}` (e.g., `repo:myorg/myapp:ref:refs/heads/main`) +- **Audience**: configurable via the `&audience=` parameter in the token request URL +- The `id-token: write` permission is required in the workflow + +
+ +
+Auth0 — OAuth2/OIDC identity platform + +#### Auth0 Setup + +1. Create an Application (Regular Web Application or SPA) in your Auth0 dashboard +2. Note your Auth0 domain — this is the issuer URL + +#### Role Configuration + +```toml +[[roles]] +role_id = "auth0-user" +name = "Auth0 User" +trusted_oidc_issuers = ["https://your-tenant.auth0.com/"] +required_audience = "https://s3proxy.example.com" +subject_conditions = ["*"] # Or restrict by user ID patterns +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +#### Key Details + +- **Issuer URL**: `https://your-tenant.auth0.com/` (trailing slash required) +- **Subject claim**: Auth0 user ID (e.g., `auth0|507f1f77bcf86cd799439011`) +- **Audience**: set when requesting the token via the `audience` parameter + +
+ +
+Keycloak — Open-source identity and access management + +#### Keycloak Setup + +1. Create a Realm and a Client in your Keycloak admin console +2. Set the client's Access Type to `public` or `confidential` as needed +3. Enable "Standard Flow" (Authorization Code) + +#### Role Configuration + +```toml +[[roles]] +role_id = "keycloak-user" +name = "Keycloak User" +trusted_oidc_issuers = ["https://keycloak.example.com/realms/myrealm"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +#### Key Details + +- **Issuer URL**: `https://keycloak.example.com/realms/{realm-name}` +- **Subject claim**: Keycloak user UUID +- **JWKS**: served at `{issuer}/protocol/openid-connect/certs` + +
+ +
+AWS Cognito — AWS-managed identity service + +#### Cognito Setup + +1. Create a User Pool in the AWS Cognito console +2. Create an App Client (no client secret for public clients) +3. Configure the Hosted UI or use the Cognito SDK for authentication + +#### Role Configuration + +```toml +[[roles]] +role_id = "cognito-user" +name = "Cognito User" +trusted_oidc_issuers = ["https://cognito-idp.us-east-1.amazonaws.com/us-east-1_EXAMPLE"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +#### Key Details + +- **Issuer URL**: `https://cognito-idp.{region}.amazonaws.com/{user-pool-id}` +- **Subject claim**: Cognito user UUID +- **Audience**: the App Client ID (set `required_audience` to match) + +
+ +
+Ory / Ory Network — Open-source OAuth2/OIDC infrastructure + +#### Ory Setup + +1. Create an OAuth2 client as a **public client** (no client secret) +2. Set the grant type to Authorization Code with PKCE +3. Register `http://127.0.0.1/callback` as a redirect URI (any port is allowed per RFC 8252) +4. Set allowed scopes to include `openid` + +#### Role Configuration + +```toml +[[roles]] +role_id = "ory-user" +name = "Ory User" +trusted_oidc_issuers = ["https://your-project.projects.oryapis.com"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +#### Key Details + +- **Issuer URL**: `https://your-project.projects.oryapis.com` (Ory Network) or your self-hosted Hydra URL +- **Subject claim**: Ory identity UUID +- The CLI (`source-coop login`) works well with Ory since Ory follows RFC 8252 for loopback redirect URIs + +
+ +## Template Variables in Scopes + +Role scopes support `{claim_name}` template variables that are resolved from the authenticated user's JWT claims when credentials are minted. This enables per-user access without creating a separate role for each user. + +```toml +[[roles]] +role_id = "source-coop-user" +trusted_oidc_issuers = ["https://auth.source.coop"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +# Each user gets access to a bucket matching their OIDC subject +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +A user with `sub = "alice"` receives credentials scoped to `bucket = "alice"`. Any string claim from the JWT can be referenced — `{email}`, `{org}`, etc. Missing or non-string claims resolve to an empty string, which safely fails authorization. + +You can also use template variables in prefixes for more granular access: + +```toml +[[roles.allowed_scopes]] +bucket = "shared-data" +prefixes = ["{org}/"] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` diff --git a/docs/auth/sealed-tokens.md b/docs/auth/sealed-tokens.md new file mode 100644 index 0000000..192d99d --- /dev/null +++ b/docs/auth/sealed-tokens.md @@ -0,0 +1,66 @@ +# Sealed Session Tokens + +When the proxy mints temporary credentials via STS, it needs a way to recognize those credentials on subsequent requests. Sealed session tokens solve this by encrypting the full credential set into the session token itself — no server-side storage required. + +## Why Sealed Tokens? + +Traditional credential stores keep a mapping from access key ID to credentials on the server. This requires either a database or in-memory state, which is impractical for stateless runtimes like Cloudflare Workers. + +Sealed tokens take a different approach: the credentials are encrypted and placed directly inside the session token that the client sends with every request. The proxy decrypts the token on each request to recover the credentials. + +## How It Works + +### Minting (seal) + +When `AssumeRoleWithWebIdentity` mints temporary credentials: + +1. The full `TemporaryCredentials` struct is serialized to JSON +2. A random 12-byte nonce is generated +3. The JSON is encrypted using AES-256-GCM with the nonce +4. The result is encoded as `base64url(nonce[12] || ciphertext + tag)` +5. This encoded string becomes the `SessionToken` returned to the client + +### Verifying (unseal) + +When a request arrives with an `x-amz-security-token` header: + +1. The proxy base64url-decodes the session token +2. It extracts the nonce (first 12 bytes) and ciphertext (remainder) +3. It decrypts using AES-256-GCM with the configured key +4. The JSON is deserialized back to `TemporaryCredentials` +5. The proxy checks that the credentials haven't expired +6. The proxy verifies the request's SigV4 signature against the decrypted secret key + +If the token doesn't look like a sealed token (e.g., not valid base64url), the proxy falls back to looking up credentials from the config provider. + +## Configuration + +Set the `SESSION_TOKEN_KEY` environment variable to a base64-encoded 32-byte key: + +```bash +# Generate a key +openssl rand -base64 32 + +# Set it +export SESSION_TOKEN_KEY="" +``` + +This key must be the same across all instances of the proxy. If you rotate the key, all existing session tokens become invalid — clients will need to re-authenticate. + +> [!WARNING] +> `SESSION_TOKEN_KEY` is required for the Cloudflare Workers runtime. Without it, temporary credentials from STS cannot be verified on subsequent requests. + +## Scope Behavior + +Access scopes are sealed into the token at mint time. This means: + +- Changing a role's `allowed_scopes` in the config only affects newly minted credentials +- Existing session tokens continue to use the scopes they were minted with until they expire +- There is no way to revoke a sealed token short of rotating the encryption key (which invalidates all tokens) + +## Security Properties + +- **Confidentiality**: AES-256-GCM encryption prevents clients from reading or modifying the sealed credentials +- **Integrity**: The GCM authentication tag detects any tampering with the ciphertext +- **Replay protection**: Each token has a random nonce; however, tokens are valid until their expiration time +- **Constant-time comparison**: The access key ID verification uses constant-time comparison to prevent timing attacks diff --git a/docs/configuration/buckets.md b/docs/configuration/buckets.md new file mode 100644 index 0000000..f3c0a1c --- /dev/null +++ b/docs/configuration/buckets.md @@ -0,0 +1,134 @@ +# Buckets + +Buckets define the virtual namespaces that clients interact with. Each bucket maps a client-visible name to a backend object store. + +## Configuration + +```toml +[[buckets]] +name = "my-data" +backend_type = "s3" +backend_prefix = "v2" +anonymous_access = false +allowed_roles = ["github-actions-deployer"] + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" +access_key_id = "AKIAIOSFODNN7EXAMPLE" +secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +``` + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Client-visible bucket name | +| `backend_type` | string | Yes | Backend provider: `"s3"`, `"az"`, or `"gcs"` | +| `backend_prefix` | string | No | Prefix prepended to keys when forwarding to the backend | +| `anonymous_access` | bool | No | Allow GET/HEAD/LIST without authentication (default: `false`) | +| `allowed_roles` | string[] | No | Role IDs that can be assumed for this bucket | +| `backend_options` | map | Yes | Provider-specific configuration (see below) | + +## Backend Options by Provider + +### S3 / MinIO / R2 + +```toml +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" +access_key_id = "AKIA..." +secret_access_key = "..." +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `endpoint` | Yes | S3 endpoint URL | +| `bucket_name` | Yes | Backend bucket name | +| `region` | Yes | AWS region | +| `access_key_id` | No | AWS access key (omit for anonymous or OIDC) | +| `secret_access_key` | No | AWS secret key | +| `skip_signature` | No | Set to `"true"` for unsigned requests | + +### Azure Blob Storage + +> [!NOTE] +> Requires the `azure` feature flag on `source-coop-core`. Enabled by default in the server runtime, not available in CF Workers. + +```toml +[buckets.backend_options] +account_name = "mystorageaccount" +container_name = "my-container" +access_key = "..." +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `account_name` | Yes | Azure storage account name | +| `container_name` | Yes | Blob container name | +| `access_key` | No | Storage account access key | +| `skip_signature` | No | Set to `"true"` for anonymous access | + +### Google Cloud Storage + +> [!NOTE] +> Requires the `gcp` feature flag on `source-coop-core`. Enabled by default in the server runtime, not available in CF Workers. + +```toml +[buckets.backend_options] +bucket_name = "my-gcs-bucket" +service_account_key = '{"type": "service_account", ...}' +``` + +| Option | Required | Description | +|--------|----------|-------------| +| `bucket_name` | Yes | GCS bucket name | +| `service_account_key` | No | JSON service account key | +| `skip_signature` | No | Set to `"true"` for anonymous access | + +### OIDC Backend Auth Options + +For any backend type, you can use OIDC-based credential resolution instead of static credentials: + +```toml +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" +auth_type = "oidc" +oidc_role_arn = "arn:aws:iam::123456789012:role/ProxyRole" +oidc_subject = "my-connection" # optional, defaults to "s3-proxy" +``` + +See [Authenticating with Backends](/auth/backend-auth) for setup details. + +## Backend Prefix + +The `backend_prefix` field transparently prepends a path prefix to all keys when forwarding requests to the backend. Clients don't see this prefix. + +```toml +[[buckets]] +name = "ml-artifacts" +backend_prefix = "v2" + +[buckets.backend_options] +bucket_name = "ml-pipeline-artifacts" +``` + +With this configuration: +- Client requests `GET /ml-artifacts/models/latest.pt` +- Proxy forwards to backend key `v2/models/latest.pt` in `ml-pipeline-artifacts` +- LIST responses have the prefix stripped so clients see `models/latest.pt` + +## Anonymous Access + +Setting `anonymous_access = true` allows unauthenticated GET, HEAD, and LIST requests. Write operations (PUT, DELETE, multipart) always require authentication regardless of this setting. + +```toml +[[buckets]] +name = "public-data" +anonymous_access = true +``` diff --git a/docs/configuration/credentials.md b/docs/configuration/credentials.md new file mode 100644 index 0000000..15e17b0 --- /dev/null +++ b/docs/configuration/credentials.md @@ -0,0 +1,67 @@ +# Credentials + +Long-lived credentials are static access key pairs stored in the proxy configuration. They work like standard AWS IAM access keys — clients sign requests using SigV4 with the access key ID and secret access key. + +## Configuration + +```toml +[[credentials]] +access_key_id = "AKPROXY00000EXAMPLE" +secret_access_key = "proxy/secret/key/EXAMPLE000000000000" +principal_name = "internal-dashboard" +created_at = "2024-01-15T00:00:00Z" +enabled = true + +[[credentials.allowed_scopes]] +bucket = "public-data" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] + +[[credentials.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/production/"] +actions = ["get_object", "head_object"] +``` + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `access_key_id` | string | Yes | Access key identifier | +| `secret_access_key` | string | Yes | Secret key for SigV4 signing | +| `principal_name` | string | Yes | Human-readable name for the credential holder | +| `created_at` | datetime | Yes | When the credential was created (ISO 8601) | +| `expires_at` | datetime | No | When the credential expires (omit for no expiration) | +| `enabled` | bool | Yes | Whether the credential is active | +| `allowed_scopes` | AccessScope[] | Yes | Buckets, prefixes, and actions granted | + +## Access Scopes + +Scopes work identically to [role scopes](./roles#access-scopes) — each scope specifies a bucket, optional prefix restrictions, and allowed actions. + +## When to Use Long-Lived Credentials + +Long-lived credentials are appropriate for: + +- **Service accounts** that need persistent access without OIDC +- **Internal tools** where token exchange adds unnecessary complexity +- **Development and testing** environments +- **Environments without an OIDC provider** + +> [!TIP] +> For CI/CD workflows and user-facing applications, prefer [OIDC/STS temporary credentials](/auth/proxy-auth#oidcsts-temporary-credentials) — they expire automatically and avoid storing secrets in config. + +## Disabling Credentials + +Set `enabled = false` to immediately revoke access without removing the credential from config: + +```toml +[[credentials]] +access_key_id = "AKPROXY00000REVOKED" +secret_access_key = "..." +principal_name = "old-service" +created_at = "2023-01-01T00:00:00Z" +enabled = false +``` + +Disabled credentials return `AccessDenied` for any request. diff --git a/docs/configuration/index.md b/docs/configuration/index.md new file mode 100644 index 0000000..293ef76 --- /dev/null +++ b/docs/configuration/index.md @@ -0,0 +1,68 @@ +# Configuration + +The proxy configuration defines three things: + +1. **[Buckets](./buckets)** — Virtual buckets that map client-visible names to backend object stores +2. **[Roles](./roles)** — Trust policies for OIDC token exchange via `AssumeRoleWithWebIdentity` +3. **[Credentials](./credentials)** — Long-lived access keys for service accounts and internal tools + +```mermaid +flowchart TD + Config["Proxy Configuration"] + Config --> Buckets["Buckets
(virtual names → backends)"] + Config --> Roles["Roles
(OIDC trust policies)"] + Config --> Creds["Credentials
(static access keys)"] + + Roles -- "allowed_scopes" --> Buckets + Creds -- "allowed_scopes" --> Buckets +``` + +## Config Format + +The server runtime uses TOML: + +```toml +[[buckets]] +name = "public-data" +backend_type = "s3" +anonymous_access = true + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-public-assets" +region = "us-east-1" +``` + +The CF Workers runtime uses JSON (as an environment variable or `wrangler.toml` object): + +```json +{ + "buckets": [{ + "name": "public-data", + "backend_type": "s3", + "anonymous_access": true, + "backend_options": { + "endpoint": "https://s3.us-east-1.amazonaws.com", + "bucket_name": "my-public-assets", + "region": "us-east-1" + } + }] +} +``` + +## Config Providers + +The proxy can load configuration from multiple backends. See [Config Providers](./providers/) for details. + +| Provider | Feature Flag | Use Case | +|----------|-------------|----------| +| [Static File](./providers/static-file) | (always available) | Simple deployments, baked-in config | +| [HTTP API](./providers/http) | `config-http` | Centralized config service | +| [DynamoDB](./providers/dynamodb) | `config-dynamodb` | AWS-native infrastructure | +| [PostgreSQL](./providers/postgres) | `config-postgres` | Database-backed config | + +All providers can be wrapped with a [cache](./providers/cached) for performance. + +## Full Example + +See the [annotated config example](/reference/config-example) for a complete configuration file with all options documented. diff --git a/docs/configuration/providers/cached.md b/docs/configuration/providers/cached.md new file mode 100644 index 0000000..a1597db --- /dev/null +++ b/docs/configuration/providers/cached.md @@ -0,0 +1,42 @@ +# Caching + +Wrap any config provider with `CachedProvider` to add in-memory TTL-based caching. This is recommended for all network-backed providers (HTTP, DynamoDB, PostgreSQL). + +## Usage + +```rust +use source_coop_core::config::cached::CachedProvider; +use std::time::Duration; + +let base = HttpProvider::new("https://config-api.internal".into(), None); +let provider = CachedProvider::new(base, Duration::from_secs(300)); +``` + +The first call hits the underlying provider; subsequent calls within the TTL return cached data. + +## Cache Behavior + +- **Thread-safe**: Uses `RwLock` internally, safe for concurrent access +- **Lazy eviction**: Expired entries are evicted on access, not proactively +- **Per-entity caching**: Each bucket, role, and credential is cached independently +- **Temporary credentials bypass**: Credential store/get operations for temporary credentials are not cached + +## Manual Invalidation + +```rust +// Invalidate everything +provider.invalidate_all(); + +// Invalidate a specific bucket +provider.invalidate_bucket("my-bucket"); +``` + +## Recommended TTLs + +| Provider | Suggested TTL | Rationale | +|----------|--------------|-----------| +| HTTP API | 60–300s | Balance between freshness and API load | +| DynamoDB | 60–300s | Reduce read capacity costs | +| PostgreSQL | 30–120s | Reduce query load | + +The server runtime's binary uses a 60-second TTL by default when wrapping the static file provider. diff --git a/docs/configuration/providers/dynamodb.md b/docs/configuration/providers/dynamodb.md new file mode 100644 index 0000000..28682a6 --- /dev/null +++ b/docs/configuration/providers/dynamodb.md @@ -0,0 +1,32 @@ +# DynamoDB Provider + +The DynamoDB provider stores configuration in a single DynamoDB table using a PK/SK (partition key / sort key) design pattern. + +## Feature Flag + +```bash +cargo build -p source-coop-server --features source-coop-core/config-dynamodb +``` + +## Usage + +```rust +use source_coop_core::config::dynamodb::DynamoDbProvider; + +let aws_config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await; +let client = aws_sdk_dynamodb::Client::new(&aws_config); +let provider = DynamoDbProvider::new(client, "source-coop-proxy-config".to_string()); +``` + +## Table Design + +The provider uses a single-table design with partition key (`PK`) and sort key (`SK`) attributes. + +## When to Use + +- AWS-native infrastructure +- Serverless deployments where a database server isn't practical +- High-availability requirements (DynamoDB's built-in replication) + +> [!TIP] +> Wrap the DynamoDB provider with [CachedProvider](./cached) to reduce read costs and latency. diff --git a/docs/configuration/providers/http.md b/docs/configuration/providers/http.md new file mode 100644 index 0000000..9451173 --- /dev/null +++ b/docs/configuration/providers/http.md @@ -0,0 +1,39 @@ +# HTTP API Provider + +The HTTP provider fetches configuration from a centralized REST API. Useful when you have a control plane service that manages proxy configuration. + +## Feature Flag + +```bash +cargo build -p source-coop-server --features source-coop-core/config-http +``` + +## Usage + +```rust +use source_coop_core::config::http::HttpProvider; + +let provider = HttpProvider::new( + "https://config-api.internal:8080".to_string(), + Some("Bearer my-api-token".to_string()), +); +``` + +## Expected API Endpoints + +The HTTP provider expects a REST API with these endpoints: + +| Endpoint | Method | Returns | +|----------|--------|---------| +| `/buckets` | GET | `Vec` | +| `/buckets/{name}` | GET | `Option` | +| `/roles/{id}` | GET | `Option` | +| `/credentials/{access_key_id}` | GET | `Option` | + +All responses should be JSON-encoded. Missing resources should return `null` or a 404 status. + +## When to Use + +- Centralized config management across multiple proxy instances +- Dynamic configuration that changes without proxy restarts (when combined with [caching](./cached)) +- Integration with a custom control plane or admin dashboard diff --git a/docs/configuration/providers/index.md b/docs/configuration/providers/index.md new file mode 100644 index 0000000..b40deef --- /dev/null +++ b/docs/configuration/providers/index.md @@ -0,0 +1,57 @@ +# Config Providers + +The proxy loads its configuration (buckets, roles, credentials) through the `ConfigProvider` trait. Multiple backends are available, selectable at build time via feature flags. + +## ConfigProvider Trait + +```rust +pub trait ConfigProvider: Clone + MaybeSend + MaybeSync + 'static { + async fn list_buckets(&self) -> Result, ProxyError>; + async fn get_bucket(&self, name: &str) -> Result, ProxyError>; + async fn get_role(&self, role_id: &str) -> Result, ProxyError>; + async fn get_credential(&self, access_key_id: &str) + -> Result, ProxyError>; +} +``` + +## Available Providers + +| Provider | Feature Flag | Best For | +|----------|-------------|----------| +| [Static File](./static-file) | (always available) | Simple deployments, single-file config | +| [HTTP API](./http) | `config-http` | Centralized config service, control planes | +| [DynamoDB](./dynamodb) | `config-dynamodb` | AWS-native infrastructure | +| [PostgreSQL](./postgres) | `config-postgres` | Database-backed config | + +All providers can be wrapped with [CachedProvider](./cached) for in-memory caching with TTL-based expiration. + +## Implementing a Custom Provider + +Implement the `ConfigProvider` trait and wrap it in `DefaultResolver` to get standard S3 proxy behavior: + +```rust +use source_coop_core::config::ConfigProvider; +use source_coop_core::error::ProxyError; +use source_coop_core::types::*; + +#[derive(Clone)] +struct MyProvider { /* ... */ } + +impl ConfigProvider for MyProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + todo!() + } + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + todo!() + } + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + todo!() + } + async fn get_credential(&self, access_key_id: &str) + -> Result, ProxyError> { + todo!() + } +} +``` + +See [Custom Config Provider](/extending/custom-provider) for a full guide. diff --git a/docs/configuration/providers/postgres.md b/docs/configuration/providers/postgres.md new file mode 100644 index 0000000..73e924b --- /dev/null +++ b/docs/configuration/providers/postgres.md @@ -0,0 +1,27 @@ +# PostgreSQL Provider + +The PostgreSQL provider stores configuration in a PostgreSQL database using sqlx. + +## Feature Flag + +```bash +cargo build -p source-coop-server --features source-coop-core/config-postgres +``` + +## Usage + +```rust +use source_coop_core::config::postgres::PostgresProvider; + +let pool = sqlx::PgPool::connect("postgres://localhost/s3proxy").await?; +let provider = PostgresProvider::new(pool); +``` + +## When to Use + +- Existing PostgreSQL infrastructure +- Relational data management preferences +- Complex queries or joins with other application data + +> [!TIP] +> Wrap the PostgreSQL provider with [CachedProvider](./cached) to reduce query load and latency. diff --git a/docs/configuration/providers/static-file.md b/docs/configuration/providers/static-file.md new file mode 100644 index 0000000..475be90 --- /dev/null +++ b/docs/configuration/providers/static-file.md @@ -0,0 +1,86 @@ +# Static File Provider + +The static file provider loads configuration from a TOML or JSON file at startup. No feature flags required — it's always available. + +## Usage + +```rust +use source_coop_core::config::static_file::StaticProvider; + +// From a TOML file +let provider = StaticProvider::from_file("config.toml")?; + +// From a TOML string +let provider = StaticProvider::from_toml(include_str!("../config.toml"))?; + +// From a JSON string (useful for CF Workers env vars) +let provider = StaticProvider::from_json(&json_string)?; +``` + +## Config Format + +### TOML + +```toml +[[buckets]] +name = "my-data" +backend_type = "s3" +anonymous_access = true + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-backend-bucket" +region = "us-east-1" + +[[roles]] +role_id = "my-role" +name = "My Role" +trusted_oidc_issuers = ["https://auth.example.com"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "my-data" +prefixes = [] +actions = ["get_object", "head_object"] + +[[credentials]] +access_key_id = "AKEXAMPLE" +secret_access_key = "secret" +principal_name = "service" +created_at = "2024-01-01T00:00:00Z" +enabled = true + +[[credentials.allowed_scopes]] +bucket = "my-data" +prefixes = [] +actions = ["get_object"] +``` + +### JSON + +```json +{ + "buckets": [{ + "name": "my-data", + "backend_type": "s3", + "anonymous_access": true, + "backend_options": { + "endpoint": "https://s3.us-east-1.amazonaws.com", + "bucket_name": "my-backend-bucket", + "region": "us-east-1" + } + }], + "roles": [], + "credentials": [] +} +``` + +## When to Use + +- Simple deployments with a single config file +- Baked-in configuration (e.g., compiled into the binary with `include_str!`) +- Cloudflare Workers (JSON via `PROXY_CONFIG` env var) +- Development and testing + +For dynamic configuration that changes without redeployment, consider [HTTP](./http), [DynamoDB](./dynamodb), or [PostgreSQL](./postgres) providers. diff --git a/docs/configuration/roles.md b/docs/configuration/roles.md new file mode 100644 index 0000000..6bb33d9 --- /dev/null +++ b/docs/configuration/roles.md @@ -0,0 +1,150 @@ +# Roles + +Roles define trust policies for OIDC token exchange via `AssumeRoleWithWebIdentity`. Each role specifies which identity providers to trust, what subject constraints to enforce, and what access scopes to grant. + +## Configuration + +```toml +[[roles]] +role_id = "github-actions-deployer" +name = "GitHub Actions Deploy Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audience = "sts.s3proxy.example.com" +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", + "repo:myorg/infrastructure:*", +] +max_session_duration_secs = 3600 + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] +actions = ["get_object", "head_object", "put_object"] + +[[roles.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/", "datasets/"] +actions = ["get_object", "head_object"] +``` + +## Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `role_id` | string | Yes | Identifier used as the `RoleArn` in STS requests | +| `name` | string | Yes | Human-readable display name | +| `trusted_oidc_issuers` | string[] | Yes | OIDC provider URLs whose tokens are accepted | +| `required_audience` | string | No | If set, the token's `aud` claim must match | +| `subject_conditions` | string[] | Yes | Glob patterns matched against the `sub` claim | +| `max_session_duration_secs` | integer | Yes | Maximum session lifetime (minimum 900s) | +| `allowed_scopes` | AccessScope[] | Yes | Buckets, prefixes, and actions granted | + +## Trust Policy Evaluation + +When a client calls `AssumeRoleWithWebIdentity`, the proxy evaluates the JWT against the role's trust policy in this order: + +1. **Issuer** — The JWT's `iss` claim must match one of `trusted_oidc_issuers` +2. **Algorithm** — Only RS256 is supported +3. **Signature** — Verified against the issuer's JWKS (fetched and cached) +4. **Audience** — If `required_audience` is set, the JWT's `aud` claim must match +5. **Subject** — The JWT's `sub` claim must match at least one `subject_conditions` pattern + +If any check fails, the STS request returns an error. + +## Subject Conditions + +Subject conditions use glob-style matching where `*` matches any sequence of characters: + +```toml +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", # Exact match + "repo:myorg/myapp:ref:refs/heads/release/*", # Prefix match + "repo:myorg/*", # Any repo in the org + "*", # Any subject +] +``` + +The `sub` claim only needs to match one of the patterns. + +## Access Scopes + +Each scope grants access to a specific bucket with optional prefix and action restrictions: + +```toml +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = ["releases/", "staging/"] +actions = ["get_object", "head_object", "put_object"] +``` + +| Field | Type | Description | +|-------|------|-------------| +| `bucket` | string | Virtual bucket name (or template variable) | +| `prefixes` | string[] | Allowed key prefixes (empty = full bucket access) | +| `actions` | string[] | Allowed S3 operations | + +### Available Actions + +| Action | S3 Operation | +|--------|-------------| +| `get_object` | GET (download) | +| `head_object` | HEAD (metadata) | +| `put_object` | PUT (upload) | +| `delete_object` | DELETE | +| `list_bucket` | LIST (list objects) | +| `create_multipart_upload` | POST (initiate multipart) | +| `upload_part` | PUT with partNumber (upload part) | +| `complete_multipart_upload` | POST with uploadId (complete multipart) | +| `abort_multipart_upload` | DELETE with uploadId (abort multipart) | + +### Prefix Matching + +Prefix matching follows these rules: + +- If the prefix ends with `/` or is empty: the key must start with the prefix +- Otherwise: the key must equal the prefix exactly, or start with the prefix followed by `/` + +> [!IMPORTANT] +> A prefix without a trailing `/` must match exactly or be followed by `/`. This prevents `data` from matching `data-private/secret.txt`. Use `data/` to restrict to that directory. + +## Template Variables + +Scope `bucket` and `prefixes` values support `{claim_name}` template variables that are resolved from the JWT claims at credential mint time: + +```toml +[[roles]] +role_id = "source-coop-user" +trusted_oidc_issuers = ["https://auth.source.coop"] +subject_conditions = ["*"] +max_session_duration_secs = 3600 + +# Each user gets access to a bucket matching their subject claim +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] +``` + +A user with `sub = "alice"` receives credentials scoped to `bucket = "alice"`. Any string claim from the JWT can be referenced — `{email}`, `{org}`, etc. + +Missing or non-string claims resolve to an empty string, which safely fails authorization. + +### Examples + +**Per-user bucket access:** +```toml +bucket = "{sub}" +``` + +**Organization-scoped prefix:** +```toml +bucket = "shared-data" +prefixes = ["{org}/"] +``` + +**Read-only access to all buckets:** +```toml +bucket = "*" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] +``` diff --git a/docs/deployment/cloudflare-workers.md b/docs/deployment/cloudflare-workers.md new file mode 100644 index 0000000..24ed80d --- /dev/null +++ b/docs/deployment/cloudflare-workers.md @@ -0,0 +1,95 @@ +# Cloudflare Workers + +The CF Workers runtime deploys the proxy to Cloudflare's edge network. It compiles to WASM and runs in the Workers V8 environment. + +## Limitations + +> [!WARNING] +> - **S3 backends only** — Azure and GCS are not supported on WASM +> - **Static or API config only** — DynamoDB and Postgres providers require Tokio, which is unavailable +> - **`SESSION_TOKEN_KEY` required** — Workers are stateless, so sealed tokens are the only way to persist temporary credentials + +## Configuration + +### `wrangler.toml` + +```toml +name = "source-coop-proxy" +main = "build/worker/shim.mjs" +compatibility_date = "2024-01-01" + +[build] +command = "cargo install worker-build && worker-build --release" + +[vars] +VIRTUAL_HOST_DOMAIN = "s3.example.com" + +[vars.PROXY_CONFIG] +buckets = [ + { name = "public-data", backend_type = "s3", anonymous_access = true, backend_options = { endpoint = "https://s3.us-east-1.amazonaws.com", bucket_name = "my-bucket", region = "us-east-1" } } +] +roles = [] +credentials = [] +``` + +`PROXY_CONFIG` can be either: +- A JSON string (via `wrangler secret put PROXY_CONFIG`) +- A JS object (via `[vars.PROXY_CONFIG]` table in `wrangler.toml`, as shown above) + +### Secrets + +Set sensitive values as secrets: + +```bash +wrangler secret put SESSION_TOKEN_KEY +wrangler secret put OIDC_PROVIDER_KEY +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `PROXY_CONFIG` | Yes | JSON config (buckets, roles, credentials) | +| `VIRTUAL_HOST_DOMAIN` | No | Domain for virtual-hosted requests | +| `SESSION_TOKEN_KEY` | For STS | Base64-encoded 32-byte AES-256-GCM key | +| `OIDC_PROVIDER_KEY` | For OIDC backend auth | PEM-encoded RSA private key | +| `OIDC_PROVIDER_ISSUER` | For OIDC backend auth | Public URL for JWKS discovery | + +## Building + +```bash +# Check +cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + +# Build (via Wrangler) +cd crates/runtimes/cf-workers +npx wrangler build +``` + +> [!WARNING] +> Always use `--target wasm32-unknown-unknown` when checking or building the CF Workers crate. It is excluded from the workspace `default-members` because WASM types won't compile on native targets. + +## Development + +```bash +cd crates/runtimes/cf-workers +npx wrangler dev +``` + +This starts a local dev server on port `8787`. + +## Deploying + +```bash +cd crates/runtimes/cf-workers +npx wrangler deploy +``` + +## Source Cooperative Mode + +When `SOURCE_API_URL` is set, the Workers runtime uses `SourceCoopResolver` instead of `DefaultResolver`. This mode: +- Resolves backends dynamically from the Source Cooperative API +- Maps URLs as `/{account_id}/{repo_id}/{key}` instead of `/{bucket}/{key}` +- Handles authorization via the Source Cooperative API permissions endpoint + +This is specific to Source Cooperative deployments and is not needed for standalone proxy use. diff --git a/docs/deployment/index.md b/docs/deployment/index.md new file mode 100644 index 0000000..62e9b89 --- /dev/null +++ b/docs/deployment/index.md @@ -0,0 +1,13 @@ +# Deployment + +The proxy can be deployed in two ways: + +| | [Server Runtime](./server) | [Cloudflare Workers](./cloudflare-workers) | +|---|---|---| +| **Best for** | Container environments (ECS, K8s, Docker) | Edge deployments, low-latency global access | +| **Backends** | S3, Azure, GCS | S3 only | +| **Scaling** | Horizontal (multiple instances) | Automatic (Cloudflare edge) | +| **Config** | TOML file + env vars | Env vars (JSON) + Wrangler secrets | +| **Complexity** | Standard ops (containers, load balancers) | Managed (no infrastructure to operate) | + +Both runtimes use the same core logic and support the same authentication flows. Choose based on your infrastructure preferences and backend requirements. diff --git a/docs/deployment/server.md b/docs/deployment/server.md new file mode 100644 index 0000000..ee5df5f --- /dev/null +++ b/docs/deployment/server.md @@ -0,0 +1,81 @@ +# Server Runtime + +The server runtime uses Tokio and Hyper to run as a native HTTP server. It supports all backend providers (S3, Azure, GCS) and all config providers. + +## Building + +```bash +# Default build (S3 + Azure + GCS backends) +cargo build --release -p source-coop-server + +# With additional config providers +cargo build --release -p source-coop-server \ + --features source-coop-core/config-dynamodb \ + --features source-coop-core/config-postgres +``` + +The binary is located at `target/release/source-coop-proxy`. + +## Running + +```bash +./target/release/source-coop-proxy \ + --config config.toml \ + --listen 0.0.0.0:8080 +``` + +### CLI Arguments + +| Flag | Default | Description | +|------|---------|-------------| +| `--config` | (required) | Path to the TOML config file | +| `--listen` | `0.0.0.0:8080` | Address and port to listen on | +| `--domain` | (none) | Domain for virtual-hosted-style requests (e.g., `s3.example.com`) | +| `--sts-config` | (none) | Optional separate TOML file for STS roles/credentials | + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `SESSION_TOKEN_KEY` | For STS | Base64-encoded 32-byte AES-256-GCM key for sealed tokens | +| `OIDC_PROVIDER_KEY` | For OIDC backend auth | PEM-encoded RSA private key | +| `OIDC_PROVIDER_ISSUER` | For OIDC backend auth | Publicly reachable URL for JWKS discovery | +| `RUST_LOG` | No | Logging level (default: `source_coop=info`) | + +Generate a session token key: + +```bash +export SESSION_TOKEN_KEY=$(openssl rand -base64 32) +``` + +## Docker + +```bash +# Build +docker build -t source-coop-proxy . + +# Run +docker run \ + -v ./config.toml:/etc/source-coop-proxy/config.toml \ + -p 8080:8080 \ + -e SESSION_TOKEN_KEY="$SESSION_TOKEN_KEY" \ + source-coop-proxy +``` + +## Config Caching + +The server binary wraps the config provider with `CachedProvider` (60-second TTL). Config changes from network-backed providers (HTTP, DynamoDB, Postgres) are picked up within 60 seconds without restarting the proxy. + +For static file configs, changes require a restart. + +## Virtual-Hosted Style + +To support virtual-hosted-style requests (`bucket.s3.example.com/key`), use the `--domain` flag: + +```bash +./source-coop-proxy --config config.toml --domain s3.example.com +``` + +Configure DNS so that `*.s3.example.com` resolves to the proxy. The proxy extracts the bucket name from the `Host` header. + +Without `--domain`, only path-style requests are supported (`/bucket/key`). diff --git a/docs/extending/custom-backend.md b/docs/extending/custom-backend.md new file mode 100644 index 0000000..b38a606 --- /dev/null +++ b/docs/extending/custom-backend.md @@ -0,0 +1,120 @@ +# Custom Backend + +The `ProxyBackend` trait abstracts runtime-specific I/O. Implement it when deploying to a platform that's neither a standard server nor Cloudflare Workers. + +## The Trait + +```rust +use source_coop_core::backend::ProxyBackend; +use source_coop_core::types::BucketConfig; +use source_coop_core::error::ProxyError; +use object_store::{ObjectStore, signer::Signer}; +use std::sync::Arc; + +pub trait ProxyBackend: Clone + MaybeSend + MaybeSync + 'static { + /// Create an ObjectStore for LIST operations + fn create_store(&self, config: &BucketConfig) -> Result, ProxyError>; + + /// Create a Signer for presigned URL generation (GET/HEAD/PUT/DELETE) + fn create_signer(&self, config: &BucketConfig) -> Result, ProxyError>; + + /// Send a pre-signed HTTP request (multipart operations) + fn send_raw( + &self, + method: http::Method, + url: String, + headers: HeaderMap, + body: Bytes, + ) -> impl Future> + MaybeSend; +} +``` + +## Three Responsibilities + +### `create_store()` + +Returns an `Arc` used only for LIST operations. The runtime may need to inject a custom HTTP connector: + +```rust +fn create_store(&self, config: &BucketConfig) -> Result, ProxyError> { + // Use the shared helper, optionally injecting a custom connector + build_object_store(config, |builder| { + match builder { + StoreBuilder::S3(s) => StoreBuilder::S3(s.with_http_connector(MyConnector)), + other => other, + } + }) +} +``` + +### `create_signer()` + +Returns an `Arc` for generating presigned URLs. Signing is pure computation — no HTTP connector needed: + +```rust +fn create_signer(&self, config: &BucketConfig) -> Result, ProxyError> { + build_signer(config) +} +``` + +### `send_raw()` + +Executes a pre-signed HTTP request for multipart operations. Use your platform's HTTP client: + +```rust +async fn send_raw( + &self, + method: http::Method, + url: String, + headers: HeaderMap, + body: Bytes, +) -> Result { + let response = self.http_client + .request(method, &url) + .headers(headers) + .body(body) + .send() + .await + .map_err(|e| ProxyError::BackendError(e.to_string()))?; + + Ok(RawResponse { + status: response.status(), + headers: response.headers().clone(), + body: response.bytes().await + .map_err(|e| ProxyError::BackendError(e.to_string()))?, + }) +} +``` + +## Helper Functions + +The `backend` module provides shared helpers: + +- **`build_object_store(config, connector_fn)`** — Dispatches on `backend_type` ("s3", "az", "gcs"), iterates `backend_options` with `with_config()`, and applies the connector function +- **`build_signer(config)`** — Returns the appropriate signer: `object_store`'s built-in signer for authenticated backends, or `UnsignedUrlSigner` for anonymous backends + +These handle the multi-provider dispatch logic so your backend implementation only needs to provide the HTTP transport layer. + +## Wiring Into the Handler + +```rust +let backend = MyBackend::new(http_client); +let resolver = DefaultResolver::new(config_provider, token_key, domain); +let handler = ProxyHandler::new(backend, resolver); + +// In your request handler, handle all three action types: +match handler.resolve_request(method, path, query, &headers).await { + HandlerAction::Forward(fwd) => { + // Execute presigned URL with your HTTP client + // Stream request body (PUT) or response body (GET) + } + HandlerAction::Response(res) => { + // Return the complete response (LIST, errors) + } + HandlerAction::NeedsBody(pending) => { + // Collect request body, then: + let result = handler.handle_with_body(pending, body).await; + // Return the result + } +} +``` diff --git a/docs/extending/custom-provider.md b/docs/extending/custom-provider.md new file mode 100644 index 0000000..edaeaf2 --- /dev/null +++ b/docs/extending/custom-provider.md @@ -0,0 +1,104 @@ +# Custom Config Provider + +The `ConfigProvider` trait defines how the proxy loads buckets, roles, and credentials. Implement it to plug in your own configuration backend. + +## The Trait + +```rust +use source_coop_core::config::ConfigProvider; +use source_coop_core::error::ProxyError; +use source_coop_core::types::*; + +pub trait ConfigProvider: Clone + MaybeSend + MaybeSync + 'static { + async fn list_buckets(&self) -> Result, ProxyError>; + async fn get_bucket(&self, name: &str) -> Result, ProxyError>; + async fn get_role(&self, role_id: &str) -> Result, ProxyError>; + async fn get_credential(&self, access_key_id: &str) + -> Result, ProxyError>; +} +``` + +## Example: Redis Provider + +```rust +use source_coop_core::config::ConfigProvider; +use source_coop_core::error::ProxyError; +use source_coop_core::types::*; + +#[derive(Clone)] +struct RedisProvider { + client: redis::Client, +} + +impl ConfigProvider for RedisProvider { + async fn list_buckets(&self) -> Result, ProxyError> { + let mut conn = self.client.get_async_connection().await + .map_err(|e| ProxyError::Internal(e.to_string()))?; + + let keys: Vec = redis::cmd("KEYS") + .arg("bucket:*") + .query_async(&mut conn) + .await + .map_err(|e| ProxyError::Internal(e.to_string()))?; + + let mut buckets = Vec::new(); + for key in keys { + let json: String = redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .map_err(|e| ProxyError::Internal(e.to_string()))?; + let bucket: BucketConfig = serde_json::from_str(&json) + .map_err(|e| ProxyError::ConfigError(e.to_string()))?; + buckets.push(bucket); + } + Ok(buckets) + } + + async fn get_bucket(&self, name: &str) -> Result, ProxyError> { + // Similar Redis GET with key "bucket:{name}" + todo!() + } + + async fn get_role(&self, role_id: &str) -> Result, ProxyError> { + todo!() + } + + async fn get_credential(&self, access_key_id: &str) + -> Result, ProxyError> { + todo!() + } +} +``` + +## Using with DefaultResolver + +Wrap your provider in `DefaultResolver` to get standard S3 proxy behavior (path/virtual-host parsing, SigV4 auth, scope-based authorization): + +```rust +use source_coop_core::resolver::DefaultResolver; +use source_coop_core::config::cached::CachedProvider; +use std::time::Duration; + +// Optional: wrap with caching +let cached = CachedProvider::new(redis_provider, Duration::from_secs(60)); + +// Create resolver with optional token key and domain +let resolver = DefaultResolver::new(cached, token_key, virtual_host_domain); + +// Wire into the proxy handler +let handler = ProxyHandler::new(backend, resolver); +``` + +## Using with CachedProvider + +For network-backed providers, wrap with `CachedProvider` to reduce latency: + +```rust +use source_coop_core::config::cached::CachedProvider; +use std::time::Duration; + +let provider = CachedProvider::new(redis_provider, Duration::from_secs(120)); +``` + +See [Caching](/configuration/providers/cached) for cache behavior details. diff --git a/docs/extending/custom-resolver.md b/docs/extending/custom-resolver.md new file mode 100644 index 0000000..ec78735 --- /dev/null +++ b/docs/extending/custom-resolver.md @@ -0,0 +1,128 @@ +# Custom Request Resolver + +The `RequestResolver` trait controls how incoming requests are parsed, authenticated, and authorized. Implement it for full control over the request handling pipeline. + +## The Trait + +```rust +use source_coop_core::resolver::{RequestResolver, ResolvedAction}; +use source_coop_core::error::ProxyError; +use http::{Method, HeaderMap}; + +pub trait RequestResolver: Clone + MaybeSend + MaybeSync + 'static { + fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> impl Future> + MaybeSend; +} +``` + +## ResolvedAction + +The resolver returns one of two actions: + +```rust +pub enum ResolvedAction { + /// Forward to a backend (standard proxy behavior) + Proxy { + operation: S3Operation, + bucket_config: BucketConfig, + list_rewrite: Option, + }, + /// Return a synthetic response (e.g., virtual listing, redirect) + Response { + status: StatusCode, + headers: HeaderMap, + body: ProxyResponseBody, + }, +} +``` + +## Example: Custom Namespace + +A resolver that maps `/{account}/{repo}/{key}` to backend buckets: + +```rust +use source_coop_core::resolver::{RequestResolver, ResolvedAction}; +use source_coop_core::s3::request::build_s3_operation; +use source_coop_core::error::ProxyError; + +#[derive(Clone)] +struct MyResolver { + api_client: ApiClient, +} + +impl RequestResolver for MyResolver { + async fn resolve( + &self, + method: &Method, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + ) -> Result { + // Parse custom URL structure + let parts: Vec<&str> = path.trim_start_matches('/').splitn(3, '/').collect(); + let (account, repo, key) = match parts.as_slice() { + [a, r, k] => (*a, *r, *k), + [a, r] => (*a, *r, ""), + _ => return Err(ProxyError::BucketNotFound), + }; + + // Look up the backend config from an external API + let bucket_config = self.api_client + .get_backend(account, repo) + .await + .map_err(|_| ProxyError::BucketNotFound)?; + + // Authenticate via external service + self.api_client + .check_permissions(account, repo, headers) + .await + .map_err(|_| ProxyError::AccessDenied)?; + + // Build the S3 operation from method + key + let operation = build_s3_operation(method, &bucket_config.name, key, query)?; + + Ok(ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite: None, + }) + } +} +``` + +## Wiring Into the Handler + +```rust +let resolver = MyResolver::new(api_client); +let handler = ProxyHandler::new(backend, resolver); + +// In your request handler: +let action = handler.resolve_request(method, path, query, &headers).await; +match action { + HandlerAction::Forward(fwd) => { /* execute presigned URL */ } + HandlerAction::Response(res) => { /* return response */ } + HandlerAction::NeedsBody(pending) => { /* collect body, call handle_with_body */ } +} +``` + +## ListRewrite + +The `ListRewrite` option in `ResolvedAction::Proxy` allows you to transform `` and `` values in LIST response XML: + +```rust +ResolvedAction::Proxy { + operation, + bucket_config, + list_rewrite: Some(ListRewrite { + strip_prefix: "internal/mirror/".to_string(), + add_prefix: "public/".to_string(), + }), +} +``` + +This is useful when the backend key structure differs from what clients expect. diff --git a/docs/extending/index.md b/docs/extending/index.md new file mode 100644 index 0000000..2c64b09 --- /dev/null +++ b/docs/extending/index.md @@ -0,0 +1,17 @@ +# Extending the Proxy + +The proxy is designed for customization through three trait boundaries. Each controls a different aspect of the proxy's behavior. + +| Trait | Controls | Default Implementation | +|-------|----------|----------------------| +| [RequestResolver](./custom-resolver) | How requests are parsed, authenticated, and authorized | `DefaultResolver` (standard S3 proxy behavior) | +| [ConfigProvider](./custom-provider) | Where configuration comes from | Static file, HTTP, DynamoDB, Postgres | +| [ProxyBackend](./custom-backend) | How the runtime interacts with backends | `ServerBackend`, `WorkerBackend` | + +## When to Customize What + +**Custom Resolver** — Your URL namespace doesn't map to `/{bucket}/{key}`, or you need external authorization (e.g., an API call), or you want different authentication logic. + +**Custom Config Provider** — You want to store config in a backend not already supported (e.g., etcd, Redis, Consul), or you need to derive config from another source. + +**Custom Backend** — You're deploying to a runtime that's neither a standard server nor Cloudflare Workers (e.g., AWS Lambda, Deno Deploy), or you need a different HTTP client. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..08be9bf --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,64 @@ +# Quick Start + +> [!NOTE] +> This guide is for administrators setting up and running their own Source Data Proxy. If you're a user looking to access data through an existing proxy, see the [User Guide](/guide/). + +The Source Data Proxy is a multi-runtime S3 gateway that proxies requests to backend object stores. This guide gets you running locally in minutes. + +## Prerequisites + +- [Rust](https://www.rust-lang.org/tools/install) (latest stable) +- [Docker](https://docs.docker.com/get-docker/) (for local development with MinIO) + +## Start the Backend + +Use Docker Compose to start MinIO as a local object store: + +```bash +docker compose up +``` + +This starts: +- MinIO API on port `9000` +- MinIO Console on port `9001` (user: `minioadmin`, password: `minioadmin`) +- A seed job that creates example buckets with test data + +## Run the Proxy + +Choose either the native server runtime or Cloudflare Workers: + +::: code-group + +```bash [Server Runtime] +cargo run -p source-coop-server -- \ + --config config.local.toml \ + --listen 0.0.0.0:8080 +``` + +```bash [Cloudflare Workers] +cd crates/runtimes/cf-workers && npx wrangler dev +``` + +::: + +The server runtime listens on port `8080`. The Workers runtime listens on port `8787`. + +## Make Your First Request + +```bash +# Anonymous read from a public bucket +curl http://localhost:8080/public-data/hello.txt + +# Signed upload with the local dev credential +AWS_ACCESS_KEY_ID=AKLOCAL0000000000001 \ +AWS_SECRET_ACCESS_KEY="localdev/secret/key/00000000000000000000" \ +aws s3 cp ./myfile.txt s3://private-uploads/myfile.txt \ + --endpoint-url http://localhost:8080 +``` + +## Next Steps + +- [Local Development](./local-development) — Detailed dev environment setup +- [Configuration](/configuration/) — Configuring buckets, roles, and credentials +- [Authentication](/auth/) — Setting up auth flows +- [Deployment](/deployment/) — Deploying to production diff --git a/docs/getting-started/local-development.md b/docs/getting-started/local-development.md new file mode 100644 index 0000000..22e242b --- /dev/null +++ b/docs/getting-started/local-development.md @@ -0,0 +1,105 @@ +# Local Development + +This guide walks through setting up a full local development environment with MinIO as the backing object store. + +## Docker Compose + +The project includes a `docker-compose.yml` that starts MinIO and seeds it with example data: + +```bash +docker compose up +``` + +This starts: +- **MinIO API** at `http://localhost:9000` +- **MinIO Console** at `http://localhost:9001` (credentials: `minioadmin` / `minioadmin`) +- A seed job that creates `public-data` and `private-uploads` buckets with sample files + +## Configuration Files + +The two runtimes use different config formats: + +### Server Runtime — `config.local.toml` + +The server runtime reads a TOML config file. The local development config points buckets at `http://localhost:9000` (MinIO): + +```bash +cargo run -p source-coop-server -- \ + --config config.local.toml \ + --listen 0.0.0.0:8080 +``` + +### Workers Runtime — `wrangler.toml` + +The CF Workers runtime reads `PROXY_CONFIG` from the Wrangler configuration. It can be a JSON string or a JS object: + +```bash +cd crates/runtimes/cf-workers && npx wrangler dev +``` + +The Workers dev server runs on port `8787` by default. + +## Building + +```bash +# Check/build default workspace members (excludes cf-workers) +cargo check +cargo build + +# CF Workers must target wasm32 +cargo check -p source-coop-cf-workers --target wasm32-unknown-unknown + +# Run tests +cargo test +``` + +## Makefile + +The project includes a Makefile with common tasks: + +```bash +make check # cargo check +make check-wasm # cargo check for CF Workers (wasm32 target) +make test # cargo test +make fmt # check formatting +make clippy # run linter +make run-server # run the server runtime +make run-workers # run the workers runtime (wrangler dev) +make ci-fast # fmt + clippy + check-wasm +make ci # ci-fast + test +``` + +## Environment Variables + +For local development, these are optional but useful: + +| Variable | Purpose | Example | +|----------|---------|---------| +| `SESSION_TOKEN_KEY` | AES-256-GCM key for sealed tokens | `openssl rand -base64 32` | +| `OIDC_PROVIDER_KEY` | RSA private key for OIDC backend auth | PEM file contents | +| `OIDC_PROVIDER_ISSUER` | Public URL for OIDC discovery | `http://localhost:8080` | +| `RUST_LOG` | Logging level | `source_coop=debug` | + +## Verifying the Setup + +Once the proxy is running, test both anonymous and authenticated access: + +```bash +# Anonymous read (should return file contents) +curl http://localhost:8080/public-data/hello.txt + +# Authenticated upload +AWS_ACCESS_KEY_ID=AKLOCAL0000000000001 \ +AWS_SECRET_ACCESS_KEY="localdev/secret/key/00000000000000000000" \ +aws s3 cp ./test.txt s3://private-uploads/test.txt \ + --endpoint-url http://localhost:8080 + +# List bucket contents +AWS_ACCESS_KEY_ID=AKLOCAL0000000000001 \ +AWS_SECRET_ACCESS_KEY="localdev/secret/key/00000000000000000000" \ +aws s3 ls s3://private-uploads/ \ + --endpoint-url http://localhost:8080 + +# Browse MinIO directly +open http://localhost:9001 +``` diff --git a/docs/guide/authentication.md b/docs/guide/authentication.md new file mode 100644 index 0000000..f1e31c4 --- /dev/null +++ b/docs/guide/authentication.md @@ -0,0 +1,133 @@ +# Authentication + +The proxy supports three ways to authenticate, depending on your use case. + +## Anonymous Access + +Public buckets serve read requests without credentials: + +```bash +curl https://data.source.coop/public-data/hello.txt +``` + +Anonymous access only allows `GetObject`, `HeadObject`, and `ListBucket` operations. Write operations always require authentication. + +## Long-Lived Access Keys + +If your administrator has issued you a static access key pair, use them like standard AWS credentials: + +```bash +AWS_ACCESS_KEY_ID=AKPROXY00000EXAMPLE \ +AWS_SECRET_ACCESS_KEY="proxy/secret/key/EXAMPLE000000000000" \ +aws s3 cp s3://my-bucket/path/to/file.txt ./file.txt \ + --endpoint-url https://data.source.coop +``` + +These work with any S3-compatible client. The proxy verifies requests using standard AWS SigV4 signing, so no special client configuration is needed beyond setting the endpoint URL. + +## OIDC / STS Temporary Credentials + +This is the recommended authentication method. You exchange a JWT from your organization's identity provider for scoped, time-limited credentials — the same flow as AWS `AssumeRoleWithWebIdentity`. + +There are two ways to do this: the CLI (for interactive use) and direct STS calls (for CI/CD and scripts). + +### CLI Authentication + +The `source-coop` CLI handles the OIDC flow for you. It opens your browser, authenticates with your identity provider, and obtains temporary credentials. + +**Install the CLI:** + +```bash +# macOS / Linux +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/source-cooperative/source-coop-cli/releases/latest/download/source-coop-cli-installer.sh | sh + +# Or from source +cargo install --git https://github.com/source-cooperative/source-coop-cli +``` + +**Log in:** + +```bash +source-coop login +``` + +This opens your browser to authenticate. Once complete, credentials are cached in your OS keyring. + +### AWS Profile Integration + +Set up an AWS profile to use the proxy seamlessly with standard AWS tools: + +```ini +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop +``` + +Then use AWS tools normally — credentials are obtained and refreshed automatically: + +```bash +aws s3 ls s3://my-bucket/ --profile source-coop +aws s3 cp ./data.csv s3://my-bucket/uploads/data.csv --profile source-coop +``` + +### Multiple Roles + +If your administrator has set up multiple roles with different access scopes, you can create a profile for each: + +```bash +source-coop login --role-arn reader-role +source-coop login --role-arn admin-role +``` + +```ini +[profile sc-reader] +credential_process = source-coop creds --role-arn reader-role +endpoint_url = https://data.source.coop + +[profile sc-admin] +credential_process = source-coop creds --role-arn admin-role +endpoint_url = https://data.source.coop +``` + +### CLI Options + +| Flag | Env Var | Default | Description | +| ------------- | ----------------------- | -------------------------- | ------------------------------------- | +| `--issuer` | `SOURCE_OIDC_ISSUER` | `https://auth.source.coop` | OIDC issuer URL | +| `--client-id` | `SOURCE_OIDC_CLIENT_ID` | (built-in) | OAuth2 client ID | +| `--proxy-url` | `SOURCE_PROXY_URL` | `https://data.source.coop` | Proxy URL for STS | +| `--role-arn` | `SOURCE_ROLE_ARN` | `source-coop-user` | Role ARN to assume | +| `--format` | | `credential-process` | Output: `credential-process` or `env` | +| `--duration` | | (role default) | Session duration in seconds | +| `--scope` | | `openid` | OAuth2 scopes | +| `--port` | | `0` (random) | Local callback server port | +| `--no-cache` | | | Skip caching credentials | + +### Direct STS Exchange + +After logging in, you can export cached credentials as environment variables: + +```bash +eval $(source-coop creds --format env) + +# Credentials are now exported — use any S3 client +aws s3 cp ./data.csv s3://deploy-bundles/data.csv \ + --endpoint-url https://data.source.coop +``` + +You can also call the STS endpoint directly with a JWT: + +```bash +CREDS=$(aws sts assume-role-with-web-identity \ + --role-arn github-actions-deployer \ + --web-identity-token "$JWT_TOKEN" \ + --endpoint-url https://data.source.coop \ + --output json) + +export AWS_ACCESS_KEY_ID=$(echo $CREDS | jq -r '.Credentials.AccessKeyId') +export AWS_SECRET_ACCESS_KEY=$(echo $CREDS | jq -r '.Credentials.SecretAccessKey') +export AWS_SESSION_TOKEN=$(echo $CREDS | jq -r '.Credentials.SessionToken') +``` + +The STS endpoint accepts a JWT from any OIDC provider that your administrator has configured as trusted. See the [Administration guide](/auth/proxy-auth) for details on setting up identity providers and trust policies. diff --git a/docs/guide/client-usage.md b/docs/guide/client-usage.md new file mode 100644 index 0000000..0a84152 --- /dev/null +++ b/docs/guide/client-usage.md @@ -0,0 +1,110 @@ +# Client Usage + +The proxy exposes a standard S3-compatible API. Any S3 client works — just set the endpoint URL to point at the proxy. + +## aws-cli + +```bash +# Download a file +aws s3 cp s3://my-bucket/path/to/file.txt ./file.txt \ + --endpoint-url https://data.source.coop + +# Upload a file +aws s3 cp ./local-file.txt s3://my-bucket/uploads/file.txt \ + --endpoint-url https://data.source.coop + +# List bucket contents +aws s3 ls s3://my-bucket/prefix/ \ + --endpoint-url https://data.source.coop +``` + +### Using AWS Profiles + +Add a profile to `~/.aws/config` to avoid specifying the endpoint every time: + +```ini +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop +``` + +Then use it: + +```bash +aws s3 ls s3://my-bucket/ --profile source-coop +aws s3 cp s3://my-bucket/data.csv ./data.csv --profile source-coop +``` + +See [Authentication](./authentication) for setting up credentials and profiles. + +## boto3 (Python) + +```python +import boto3 + +s3 = boto3.client( + "s3", + endpoint_url="https://data.source.coop", + aws_access_key_id="AKPROXY00000EXAMPLE", + aws_secret_access_key="proxy/secret/key/EXAMPLE", +) + +# Download +s3.download_file("my-bucket", "path/to/file.txt", "./file.txt") + +# Upload +s3.upload_file("./local-file.txt", "my-bucket", "uploads/file.txt") + +# List +response = s3.list_objects_v2(Bucket="my-bucket", Prefix="prefix/") +for obj in response.get("Contents", []): + print(obj["Key"]) +``` + +### Using a Profile with boto3 + +If you have an AWS profile configured with `credential_process`: + +```python +import boto3 + +session = boto3.Session(profile_name="source-coop") +s3 = session.client("s3") + +response = s3.list_objects_v2(Bucket="my-bucket") +``` + +## curl + +For anonymous buckets, you can use curl directly: + +```bash +# Download +curl https://data.source.coop/public-data/hello.txt + +# HEAD request (metadata only) +curl -I https://data.source.coop/public-data/hello.txt +``` + +> [!NOTE] +> Authenticated requests require SigV4 signing. Use aws-cli or an SDK rather than raw curl. + +## Request Styles + +The proxy supports two S3 URL styles: + +### Path Style (default) + +``` +https://data.source.coop/bucket-name/key/path +``` + +This is the default and works without additional configuration. + +### Virtual-Hosted Style + +``` +https://bucket-name.s3.example.com/key/path +``` + +Virtual-hosted style requires that the proxy administrator has configured the `--domain` flag. The proxy extracts the bucket name from the `Host` header. diff --git a/docs/guide/endpoints.md b/docs/guide/endpoints.md new file mode 100644 index 0000000..f5eddeb --- /dev/null +++ b/docs/guide/endpoints.md @@ -0,0 +1,101 @@ +# Endpoints + +Source Cooperative runs two types of proxy deployments. Choosing the right endpoint can significantly improve throughput and reduce costs. + +## Global Endpoint (Cloudflare Workers) + +``` +https://data.source.coop +``` + +The primary endpoint runs on Cloudflare Workers at the edge. This is the default for most use cases: + +- **Global availability** — Requests are handled by the nearest Cloudflare edge location +- **Backbone routing** — Traffic between Cloudflare and AWS travels over Cloudflare's private backbone network rather than the public internet, improving throughput and reliability +- **Best for** — Workstations, laptops, CI/CD outside AWS, or any client not running inside an AWS region + +```bash +aws s3 cp s3://my-bucket/data.parquet ./data.parquet \ + --endpoint-url https://data.source.coop +``` + +## Regional Endpoints (AWS Servers) + +``` +https://{region}.data.source.coop +``` + +For workloads running inside AWS, zone-specific server deployments are available. These run as native Tokio/Hyper servers within the same AWS region as the backend storage: + +| Endpoint | Region | +|----------|--------| +| `us-west-2.data.source.coop` | US West (Oregon) | +| `us-east-1.data.source.coop` | US East (N. Virginia) | + +Regional endpoints provide two major advantages: + +- **Higher throughput** — Traffic stays within the AWS network, avoiding internet bottlenecks. This is especially impactful for large file transfers and batch processing workloads +- **No egress fees** — Data transferred between S3 and an EC2 instance (or other AWS service) in the same region incurs no AWS data transfer charges. Using the global endpoint from within AWS would route traffic out through Cloudflare and back, incurring egress fees on both legs + +### When to Use Regional Endpoints + +Use a regional endpoint when your client is running inside the same AWS region as the data: + +- **EC2 instances** processing datasets stored in the same region +- **SageMaker notebooks** or training jobs accessing training data +- **Lambda functions** reading/writing data in batch pipelines +- **ECS/EKS workloads** performing ETL or analytics +- **AWS Batch** jobs processing large datasets + +```bash +# From an EC2 instance in us-west-2 +aws s3 cp s3://my-bucket/large-dataset.parquet ./data.parquet \ + --endpoint-url https://us-west-2.data.source.coop +``` + +### AWS Profile Configuration + +Set up profiles for both global and regional access: + +```ini +# For general use (laptop, CI/CD outside AWS) +[profile source-coop] +credential_process = source-coop creds +endpoint_url = https://data.source.coop + +# For workloads in us-west-2 +[profile source-coop-usw2] +credential_process = source-coop creds +endpoint_url = https://us-west-2.data.source.coop +``` + +```bash +# From your laptop +aws s3 ls s3://my-bucket/ --profile source-coop + +# From an EC2 instance in us-west-2 +aws s3 ls s3://my-bucket/ --profile source-coop-usw2 +``` + +## Choosing an Endpoint + +```mermaid +flowchart TD + Start["Where is your client running?"] + Start -->|Inside AWS| Region["Same region as the data?"] + Start -->|Outside AWS| Global["Use data.source.coop"] + + Region -->|Yes| Regional["Use {region}.data.source.coop"] + Region -->|No / Unsure| Global +``` + +| Scenario | Recommended Endpoint | Why | +|----------|---------------------|-----| +| Laptop or workstation | `data.source.coop` | Cloudflare backbone optimizes global routing | +| GitHub Actions / CI | `data.source.coop` | CI runners are typically outside AWS | +| EC2 in us-west-2, data in us-west-2 | `us-west-2.data.source.coop` | Same-region: max throughput, zero egress | +| EC2 in us-east-1, data in us-west-2 | `data.source.coop` | Cross-region: Cloudflare backbone is faster than cross-region AWS traffic | +| SageMaker in us-west-2 | `us-west-2.data.source.coop` | Same-region: zero egress for training data | + +> [!TIP] +> All endpoints support the same authentication methods and S3 operations. Your credentials work across any endpoint — only the `endpoint_url` changes. diff --git a/docs/guide/index.md b/docs/guide/index.md new file mode 100644 index 0000000..3766cc7 --- /dev/null +++ b/docs/guide/index.md @@ -0,0 +1,24 @@ +# User Guide + +The Source Data Proxy provides S3-compatible access to data stored across multiple cloud backends. You interact with it using standard S3 tools — `aws-cli`, `boto3`, or any S3-compatible SDK — just point the endpoint URL at the proxy. + +## Getting Started + +1. **[Endpoints](./endpoints)** — Which endpoint to use (global vs. regional) +2. **[Authentication](./authentication)** — How to authenticate and obtain credentials +3. **[Client Usage](./client-usage)** — Using aws-cli, boto3, curl, and other S3 clients + +## Quick Example + +```bash +# Anonymous access to a public bucket +curl https://data.source.coop/public-data/hello.txt + +# Authenticated access with the CLI +source-coop login +aws s3 ls s3://my-bucket/ --profile source-coop +``` + +## How It Works + +The proxy sits between your S3 client and the backend object stores. You send standard S3 requests to the proxy, and it handles authentication, authorization, and forwarding to the correct backend. From your perspective, it behaves like any other S3-compatible service. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..49cbf6a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,89 @@ +--- +layout: home + +hero: + name: Source Data Proxy + text: A multi-runtime S3 gateway proxy with authentication, authorization, and zero-copy passthrough. + tagline: 'Built for reuse. the Source Data Proxy is the backend API powering source.coop' + actions: + - theme: brand + text: User Guide + link: /guide/ + - theme: alt + text: Administration + link: /getting-started/ + - theme: alt + text: View on GitHub + link: https://github.com/source-cooperative/data.source.coop + +features: + - title: Unified Interface + details: One stable URL per dataset, regardless of which object storage provider hosts the bytes. Backend migrations are invisible to data consumers. + - title: Native S3 Compatibility + details: Works with aws-cli, boto3, DuckDB, the obstore, GDAL, and any S3-compatible client. No custom SDK — just set the endpoint URL. + - title: Metered Access + details: Enforce per-identity rate limits so open data stays free for humans while protecting infrastructure from runaway machine access and egress costs. + - title: Flexible Auth + details: OIDC token exchange for both frontend (user/machine identity) and backend (cloud storage credentials). No long-lived keys anywhere in the chain. + - title: Multi-Runtime + details: Same core logic deploys as a native Tokio/Hyper server in containers or as a Cloudflare Worker at the edge. + - title: Zero-Copy Streaming + details: Presigned URLs enable direct streaming between clients and backends. No buffering, no double-handling of request or response bodies. +--- + +## Why a Proxy? + +[Source Cooperative](https://source.coop) hosts open data from researchers and organizations around the world. That data lives on object storage — but object storage alone doesn't solve the problems that come with making data truly accessible. + +### One URL, any backend + +A dataset might start on AWS S3, move to Cloudflare R2 to reduce egress costs, or get mirrored across providers for redundancy. The proxy gives every data product a stable URL (`data.source.coop/{virtual-bucket}...`) regardless of where the bytes actually live. Backend migrations are invisible to consumers — no broken links, no client reconfiguration. + +### Native S3 compatibility + +Rather than inventing a new API, the proxy speaks the S3 protocol. This means the entire ecosystem of existing tools — `aws-cli`, `boto3`, DuckDB, the Rust `object_store` crate, the Python `obstore` module, GDAL, and hundreds of others — work out of the box. Users don't install a custom client or learn a new SDK. They just set an endpoint URL. + +### Metered access + +Open data should be free and open to humans, but without guardrails a single runaway script can rack up thousands of dollars in egress charges. The proxy enables metered access — enforcing limits on how much data a given identity can consume in a window of time. Public datasets stay freely accessible while the infrastructure stays sustainable. + +### Flexible authentication + +The proxy supports two layers of OIDC-based auth that eliminate long-lived credentials: + +- **Frontend**: Third-party identity providers (GitHub Actions, Auth0, Keycloak) can exchange OIDC tokens for scoped, time-limited proxy credentials — enabling machine-to-machine workflows like ETL pipelines and CI/CD without sharing static keys. +- **Backend**: The proxy acts as its own OIDC identity provider to authenticate with cloud storage backends, replacing long-lived access keys with short-lived credentials obtained via token exchange. + +### Run anywhere + +The same core logic compiles to a native Tokio/Hyper server for container deployments and to WebAssembly for Cloudflare Workers at the edge. Choose the runtime that fits your infrastructure — or run both. + +## How It Works + +```mermaid +flowchart LR + Clients["S3 Clients
(aws-cli, boto3, SDKs)"] + + subgraph Proxy["source-coop-proxy"] + Auth["Auth
(STS, OIDC, SigV4)"] + Core["Core
(Proxy Handler)"] + Config["Config
(Static, HTTP, DynamoDB, Postgres)"] + end + + Backend["Backend Stores
(AWS S3, MinIO, R2, Azure, GCS)"] + + Clients <--> Proxy + Proxy <--> Backend +``` + +The proxy sits between S3-compatible clients and backend object stores. It authenticates incoming requests, authorizes them against configured scopes, and forwards them to the appropriate backend using presigned URLs for zero-copy streaming. + +## Get Started + +### Access data on Source Cooperative + +The [User Guide](/guide/) covers how to interact with Source Cooperative's hosted data proxy at `data.source.coop` — browsing datasets, authenticating, and using standard S3 clients to read and write data. + +### Build your own data proxy + +The [Administration](/getting-started/) section covers how to deploy and configure the Source Data Proxy for your own project — setting up backends, defining buckets and roles, configuring authentication, and extending the proxy with custom resolvers and providers. diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 0000000..7f2004e --- /dev/null +++ b/docs/package.json @@ -0,0 +1,20 @@ +{ + "name": "source-data-proxy-docs", + "private": true, + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "devDependencies": { + "@braintree/sanitize-url": "^7.1.2", + "cytoscape": "^3.33.1", + "cytoscape-cose-bilkent": "^4.1.0", + "dayjs": "^1.11.19", + "debug": "^4.4.3", + "mermaid": "^11.4.1", + "vitepress": "^1.6.3", + "vitepress-plugin-mermaid": "^2.0.17" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 0000000..5376ad6 --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,2620 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@braintree/sanitize-url': + specifier: ^7.1.2 + version: 7.1.2 + cytoscape: + specifier: ^3.33.1 + version: 3.33.1 + cytoscape-cose-bilkent: + specifier: ^4.1.0 + version: 4.1.0(cytoscape@3.33.1) + dayjs: + specifier: ^1.11.19 + version: 1.11.19 + debug: + specifier: ^4.4.3 + version: 4.4.3 + mermaid: + specifier: ^11.4.1 + version: 11.12.3 + vitepress: + specifier: ^1.6.3 + version: 1.6.4(@algolia/client-search@5.49.1)(postcss@8.5.6)(search-insights@2.17.3) + vitepress-plugin-mermaid: + specifier: ^2.0.17 + version: 2.0.17(mermaid@11.12.3)(vitepress@1.6.4(@algolia/client-search@5.49.1)(postcss@8.5.6)(search-insights@2.17.3)) + +packages: + + '@algolia/abtesting@1.15.1': + resolution: {integrity: sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw==} + engines: {node: '>= 14.0.0'} + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.49.1': + resolution: {integrity: sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.49.1': + resolution: {integrity: sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.49.1': + resolution: {integrity: sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.49.1': + resolution: {integrity: sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.49.1': + resolution: {integrity: sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.49.1': + resolution: {integrity: sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.49.1': + resolution: {integrity: sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.49.1': + resolution: {integrity: sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.49.1': + resolution: {integrity: sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.49.1': + resolution: {integrity: sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.49.1': + resolution: {integrity: sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.49.1': + resolution: {integrity: sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.49.1': + resolution: {integrity: sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA==} + engines: {node: '>= 14.0.0'} + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@6.0.4': + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@chevrotain/cst-dts-gen@11.1.2': + resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} + + '@chevrotain/gast@11.1.2': + resolution: {integrity: sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==} + + '@chevrotain/regexp-to-ast@11.1.2': + resolution: {integrity: sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@chevrotain/utils@11.1.2': + resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.71': + resolution: {integrity: sha512-rNoDFbq1fAYiEexBvrw613/xiUOPEu5MKVV/X8lI64AgdTzLQUUemr9f9fplxUMPoxCBP2rWzlhOEeTHk/Sf0Q==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.0': + resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@mermaid-js/mermaid-mindmap@9.3.0': + resolution: {integrity: sha512-IhtYSVBBRYviH1Ehu8gk69pMDF8DSRqXBRDMWrEfHoaMruHeaP2DXA3PBnuwsMaCdPQhlUUcy/7DBLAEIXvCAw==} + + '@mermaid-js/parser@1.0.0': + resolution: {integrity: sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.3': + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.29': + resolution: {integrity: sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==} + + '@vue/compiler-dom@3.5.29': + resolution: {integrity: sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==} + + '@vue/compiler-sfc@3.5.29': + resolution: {integrity: sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==} + + '@vue/compiler-ssr@3.5.29': + resolution: {integrity: sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.29': + resolution: {integrity: sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==} + + '@vue/runtime-core@3.5.29': + resolution: {integrity: sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==} + + '@vue/runtime-dom@3.5.29': + resolution: {integrity: sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==} + + '@vue/server-renderer@3.5.29': + resolution: {integrity: sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==} + peerDependencies: + vue: 3.5.29 + + '@vue/shared@3.5.29': + resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + algoliasearch@5.49.1: + resolution: {integrity: sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ==} + engines: {node: '>= 14.0.0'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + chevrotain-allstar@0.3.1: + resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} + peerDependencies: + chevrotain: ^11.0.0 + + chevrotain@11.1.2: + resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.33.1: + resolution: {integrity: sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.13: + resolution: {integrity: sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dompurify@3.3.1: + resolution: {integrity: sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + focus-trap@7.8.0: + resolution: {integrity: sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + katex@0.16.33: + resolution: {integrity: sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + langium@4.2.1: + resolution: {integrity: sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mermaid@11.12.3: + resolution: {integrity: sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.0: + resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + non-layered-tidy-tree-layout@2.0.2: + resolution: {integrity: sha512-gkXMxRzUH+PB0ax9dUN0yYF0S25BqeAYqhgMaLUFmpXLEk7Fcu8f4emJuOAY0V8kjDICxROIKsTAKsV/v355xw==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stylis@4.3.6: + resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + ufo@1.6.3: + resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress-plugin-mermaid@2.0.17: + resolution: {integrity: sha512-IUzYpwf61GC6k0XzfmAmNrLvMi9TRrVRMsUyCA8KNXhg/mQ1VqWnO0/tBVPiX5UoKF1mDUwqn5QV4qAJl6JnUg==} + peerDependencies: + mermaid: 10 || 11 + vitepress: ^1.0.0 || ^1.0.0-alpha + + vitepress@1.6.4: + resolution: {integrity: sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue@3.5.29: + resolution: {integrity: sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/abtesting@1.15.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)': + dependencies: + '@algolia/client-search': 5.49.1 + algoliasearch: 5.49.1 + + '@algolia/client-abtesting@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-analytics@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-common@5.49.1': {} + + '@algolia/client-insights@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-personalization@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-query-suggestions@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/client-search@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/ingestion@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/monitoring@1.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/recommend@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + '@algolia/requester-browser-xhr@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@algolia/requester-fetch@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@algolia/requester-node-http@5.49.1': + dependencies: + '@algolia/client-common': 5.49.1 + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@braintree/sanitize-url@6.0.4': + optional: true + + '@braintree/sanitize-url@7.1.2': {} + + '@chevrotain/cst-dts-gen@11.1.2': + dependencies: + '@chevrotain/gast': 11.1.2 + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/gast@11.1.2': + dependencies: + '@chevrotain/types': 11.1.2 + lodash-es: 4.17.23 + + '@chevrotain/regexp-to-ast@11.1.2': {} + + '@chevrotain/types@11.1.2': {} + + '@chevrotain/utils@11.1.2': {} + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.17.3) + preact: 10.28.4 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.49.1)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.49.1)(algoliasearch@5.49.1) + '@docsearch/css': 3.8.2 + algoliasearch: 5.49.1 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@iconify-json/simple-icons@1.2.71': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.0': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + mlly: 1.8.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@mermaid-js/mermaid-mindmap@9.3.0': + dependencies: + '@braintree/sanitize-url': 6.0.4 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + khroma: 2.1.0 + non-layered-tidy-tree-layout: 2.0.2 + optional: true + + '@mermaid-js/parser@1.0.0': + dependencies: + langium: 4.2.1 + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.3': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/estree@1.0.8': {} + + '@types/geojson@7946.0.16': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/trusted-types@2.0.7': + optional: true + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.29)': + dependencies: + vite: 5.4.21 + vue: 3.5.29 + + '@vue/compiler-core@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/shared': 3.5.29 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.29': + dependencies: + '@vue/compiler-core': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/compiler-sfc@3.5.29': + dependencies: + '@babel/parser': 7.29.0 + '@vue/compiler-core': 3.5.29 + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.29': + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.29': + dependencies: + '@vue/shared': 3.5.29 + + '@vue/runtime-core@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/shared': 3.5.29 + + '@vue/runtime-dom@3.5.29': + dependencies: + '@vue/reactivity': 3.5.29 + '@vue/runtime-core': 3.5.29 + '@vue/shared': 3.5.29 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.29(vue@3.5.29)': + dependencies: + '@vue/compiler-ssr': 3.5.29 + '@vue/shared': 3.5.29 + vue: 3.5.29 + + '@vue/shared@3.5.29': {} + + '@vueuse/core@12.8.2': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.29 + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)': + dependencies: + '@vueuse/core': 12.8.2 + '@vueuse/shared': 12.8.2 + vue: 3.5.29 + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@12.8.2': + dependencies: + vue: 3.5.29 + transitivePeerDependencies: + - typescript + + acorn@8.16.0: {} + + algoliasearch@5.49.1: + dependencies: + '@algolia/abtesting': 1.15.1 + '@algolia/client-abtesting': 5.49.1 + '@algolia/client-analytics': 5.49.1 + '@algolia/client-common': 5.49.1 + '@algolia/client-insights': 5.49.1 + '@algolia/client-personalization': 5.49.1 + '@algolia/client-query-suggestions': 5.49.1 + '@algolia/client-search': 5.49.1 + '@algolia/ingestion': 1.49.1 + '@algolia/monitoring': 1.49.1 + '@algolia/recommend': 5.49.1 + '@algolia/requester-browser-xhr': 5.49.1 + '@algolia/requester-fetch': 5.49.1 + '@algolia/requester-node-http': 5.49.1 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + chevrotain-allstar@0.3.1(chevrotain@11.1.2): + dependencies: + chevrotain: 11.1.2 + lodash-es: 4.17.23 + + chevrotain@11.1.2: + dependencies: + '@chevrotain/cst-dts-gen': 11.1.2 + '@chevrotain/gast': 11.1.2 + '@chevrotain/regexp-to-ast': 11.1.2 + '@chevrotain/types': 11.1.2 + '@chevrotain/utils': 11.1.2 + lodash-es: 4.17.23 + + comma-separated-tokens@2.0.3: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + confbox@0.1.8: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.33.1 + + cytoscape-fcose@2.2.0(cytoscape@3.33.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.33.1 + + cytoscape@3.33.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.0.1 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.13: + dependencies: + d3: 7.9.0 + lodash-es: 4.17.23 + + dayjs@1.11.19: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delaunator@5.0.1: + dependencies: + robust-predicates: 3.0.2 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dompurify@3.3.1: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + emoji-regex-xs@1.0.0: {} + + entities@7.0.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + focus-trap@7.8.0: + dependencies: + tabbable: 6.4.0 + + fsevents@2.3.3: + optional: true + + hachure-fill@0.5.2: {} + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-what@5.5.0: {} + + katex@0.16.33: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + langium@4.2.1: + dependencies: + chevrotain: 11.1.2 + chevrotain-allstar: 0.3.1(chevrotain@11.1.2) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lodash-es@4.17.23: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + marked@16.4.2: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mermaid@11.12.3: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.0 + '@mermaid-js/parser': 1.0.0 + '@types/d3': 7.4.3 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.13 + dayjs: 1.11.19 + dompurify: 3.3.1 + katex: 0.16.33 + khroma: 2.1.0 + lodash-es: 4.17.23 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + mitt@3.0.1: {} + + mlly@1.8.0: + dependencies: + acorn: 8.16.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.3 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + non-layered-tidy-tree-layout@2.0.2: + optional: true + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.1.0 + regex-recursion: 6.0.2 + + package-manager-detector@1.6.0: {} + + path-data-parser@0.1.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.0 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.28.4: {} + + property-information@7.1.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rfdc@1.4.1: {} + + robust-predicates@3.0.2: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + search-insights@2.17.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stylis@4.3.6: {} + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tabbable@6.4.0: {} + + tinyexec@1.0.2: {} + + trim-lines@3.0.1: {} + + ts-dedent@2.2.0: {} + + ufo@1.6.3: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + uuid@11.1.0: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.59.0 + optionalDependencies: + fsevents: 2.3.3 + + vitepress-plugin-mermaid@2.0.17(mermaid@11.12.3)(vitepress@1.6.4(@algolia/client-search@5.49.1)(postcss@8.5.6)(search-insights@2.17.3)): + dependencies: + mermaid: 11.12.3 + vitepress: 1.6.4(@algolia/client-search@5.49.1)(postcss@8.5.6)(search-insights@2.17.3) + optionalDependencies: + '@mermaid-js/mermaid-mindmap': 9.3.0 + + vitepress@1.6.4(@algolia/client-search@5.49.1)(postcss@8.5.6)(search-insights@2.17.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.1)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.71 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.29) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.29 + '@vueuse/core': 12.8.2 + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21 + vue: 3.5.29 + optionalDependencies: + postcss: 8.5.6 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vscode-jsonrpc@8.2.0: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-uri@3.1.0: {} + + vue@3.5.29: + dependencies: + '@vue/compiler-dom': 3.5.29 + '@vue/compiler-sfc': 3.5.29 + '@vue/runtime-dom': 3.5.29 + '@vue/server-renderer': 3.5.29(vue@3.5.29) + '@vue/shared': 3.5.29 + + zwitch@2.0.4: {} diff --git a/docs/reference/config-example.md b/docs/reference/config-example.md new file mode 100644 index 0000000..b97b264 --- /dev/null +++ b/docs/reference/config-example.md @@ -0,0 +1,164 @@ +# Configuration Example + +A complete, annotated configuration file showing all available options. + +```toml +# ============================================================================= +# Virtual Buckets +# ============================================================================= + +# A publicly accessible S3 bucket (anonymous reads allowed) +[[buckets]] +name = "public-data" # Client-visible bucket name +backend_type = "s3" # Backend provider: "s3", "az", or "gcs" +anonymous_access = true # Allow GET/HEAD/LIST without auth +allowed_roles = [] # No STS roles (anonymous only) + +[buckets.backend_options] +endpoint = "https://s3.us-east-1.amazonaws.com" +bucket_name = "my-company-public-assets" # Actual backend bucket name +region = "us-east-1" +access_key_id = "AKIAIOSFODNN7EXAMPLE" +secret_access_key = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + +# A private S3 bucket backed by MinIO with a backend prefix +[[buckets]] +name = "ml-artifacts" +backend_type = "s3" +backend_prefix = "v2" # Prepend "v2/" to all keys when forwarding +anonymous_access = false +allowed_roles = ["github-actions-deployer"] + +[buckets.backend_options] +endpoint = "https://minio.internal:9000" +bucket_name = "ml-pipeline-artifacts" +region = "us-east-1" +access_key_id = "minioadmin" +secret_access_key = "minioadmin" + +# An S3 bucket on a different region +[[buckets]] +name = "deploy-bundles" +backend_type = "s3" +anonymous_access = false +allowed_roles = ["github-actions-deployer", "ci-readonly"] + +[buckets.backend_options] +endpoint = "https://s3.us-west-2.amazonaws.com" +bucket_name = "prod-deploy-bundles" +region = "us-west-2" +access_key_id = "AKIAI44QH8DHBEXAMPLE" +secret_access_key = "je7MtGbClwBF/2Zp9Utk/h3yCo8nvbEXAMPLEKEY" + +# An Azure Blob Storage backend (requires "azure" feature) +[[buckets]] +name = "azure-data" +backend_type = "az" +anonymous_access = true +allowed_roles = [] + +[buckets.backend_options] +account_name = "mystorageaccount" +container_name = "public-datasets" + +# ============================================================================= +# IAM Roles (for STS AssumeRoleWithWebIdentity) +# ============================================================================= + +# Role for GitHub Actions CI/CD pipelines +[[roles]] +role_id = "github-actions-deployer" # Used as RoleArn in STS requests +name = "GitHub Actions Deploy Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +required_audience = "sts.s3proxy.example.com" # Token's `aud` must match + +# Glob patterns for the `sub` claim +subject_conditions = [ + "repo:myorg/myapp:ref:refs/heads/main", + "repo:myorg/myapp:ref:refs/heads/release/*", + "repo:myorg/infrastructure:*", +] +max_session_duration_secs = 3600 # 1 hour max + +# Scopes granted to minted credentials +[[roles.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/", "datasets/"] # Restrict to these prefixes +actions = [ + "get_object", "head_object", "put_object", + "create_multipart_upload", "upload_part", "complete_multipart_upload" +] + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] # Full bucket access +actions = [ + "get_object", "head_object", "put_object", + "create_multipart_upload", "upload_part", "complete_multipart_upload" +] + +# Role with template variables for per-user access +[[roles]] +role_id = "source-user" +name = "Source Cooperative User" +trusted_oidc_issuers = ["https://auth.source.coop", "https://auth.staging.source.coop"] +subject_conditions = ["*"] # Any subject +max_session_duration_secs = 3600 + +# {sub} is replaced with the JWT's `sub` claim at mint time +[[roles.allowed_scopes]] +bucket = "{sub}" +prefixes = [] +actions = ["get_object", "head_object", "put_object", "list_bucket"] + +# Read-only role for CI +[[roles]] +role_id = "ci-readonly" +name = "CI Read-Only Role" +trusted_oidc_issuers = ["https://token.actions.githubusercontent.com"] +subject_conditions = ["repo:myorg/*"] # Any repo in the org +max_session_duration_secs = 1800 # 30 minutes + +[[roles.allowed_scopes]] +bucket = "deploy-bundles" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] + +# ============================================================================= +# Long-Lived Credentials +# ============================================================================= + +# Service account for an internal tool +[[credentials]] +access_key_id = "AKPROXY00000EXAMPLE" +secret_access_key = "proxy/secret/key/EXAMPLE000000000000" +principal_name = "internal-dashboard" +created_at = "2024-01-15T00:00:00Z" +enabled = true # Set to false to revoke + +[[credentials.allowed_scopes]] +bucket = "public-data" +prefixes = [] +actions = ["get_object", "head_object", "list_bucket"] + +[[credentials.allowed_scopes]] +bucket = "ml-artifacts" +prefixes = ["models/production/"] +actions = ["get_object", "head_object"] +``` + +## Environment Variables + +These are set separately from the config file: + +```bash +# Required for STS temporary credentials (sealed tokens) +export SESSION_TOKEN_KEY=$(openssl rand -base64 32) + +# Required for OIDC backend auth +export OIDC_PROVIDER_KEY=$(cat oidc-key.pem) +export OIDC_PROVIDER_ISSUER="https://data.source.coop" + +# Logging +export RUST_LOG="source_coop=info" +``` diff --git a/docs/reference/errors.md b/docs/reference/errors.md new file mode 100644 index 0000000..79bc703 --- /dev/null +++ b/docs/reference/errors.md @@ -0,0 +1,57 @@ +# Error Codes + +The proxy returns S3-compatible error responses in XML format: + +```xml + + AccessDenied + Access Denied + 550e8400-e29b-41d4-a716-446655440000 + +``` + +## Error Types + +| Error | HTTP Status | S3 Code | When | +|-------|------------|---------|------| +| BucketNotFound | 404 | `NoSuchBucket` | Requested bucket doesn't exist in config | +| NoSuchKey | 404 | `NoSuchKey` | Key not found in backend (forwarded from backend response) | +| AccessDenied | 403 | `AccessDenied` | Caller lacks permission for the requested operation | +| SignatureDoesNotMatch | 403 | `SignatureDoesNotMatch` | SigV4 signature verification failed | +| MissingAuth | 403 | `AccessDenied` | Authentication required but no credentials provided | +| ExpiredCredentials | 403 | `ExpiredToken` | Temporary credentials have expired | +| InvalidOidcToken | 400 | `InvalidIdentityToken` | JWT validation failed (bad signature, untrusted issuer, etc.) | +| RoleNotFound | 403 | `AccessDenied` | Requested role doesn't exist in config | +| InvalidRequest | 400 | `InvalidRequest` | Malformed S3 request | +| BackendError | 503 | `ServiceUnavailable` | Backend object store is unreachable or returned an error | +| PreconditionFailed | 412 | `PreconditionFailed` | Conditional request failed (If-Match, etc.) | +| NotModified | 304 | `NotModified` | Conditional request — content not changed | +| ConfigError | 500 | `InternalError` | Invalid proxy configuration | +| Internal | 500 | `InternalError` | Unexpected internal error | + +## STS Error Responses + +STS errors follow the AWS STS error format: + +```xml + + + InvalidIdentityToken + Token signature verification failed + + 550e8400-e29b-41d4-a716-446655440000 + +``` + +| HTTP Status | Code | When | +|------------|------|------| +| 400 | `MalformedPolicyDocument` | Role not found in config | +| 400 | `InvalidIdentityToken` | JWT invalid, untrusted issuer, algorithm unsupported, subject mismatch | +| 400 | `InvalidParameterValue` | Missing required STS parameters | +| 403 | `AccessDenied` | General authorization failure | +| 500 | `InternalError` | Unexpected error during token exchange | + +## Error Message Safety + +> [!NOTE] +> For 5xx errors, the proxy returns generic messages to avoid leaking internal infrastructure details. The full error is logged server-side but not exposed to clients. For 4xx errors, descriptive messages are returned to help clients debug authentication and authorization issues. diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..0ea31a6 --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,5 @@ +# Reference + +- [Supported Operations](./operations) — S3 operations the proxy handles, including dispatch method +- [Error Codes](./errors) — Error types, S3 error codes, and HTTP status codes +- [Config Example](./config-example) — Annotated full configuration file diff --git a/docs/reference/operations.md b/docs/reference/operations.md new file mode 100644 index 0000000..4547d1d --- /dev/null +++ b/docs/reference/operations.md @@ -0,0 +1,44 @@ +# Supported Operations + +## S3 Operations + +| Operation | HTTP Method | Dispatch | Description | +|-----------|------------|----------|-------------| +| GetObject | `GET /{bucket}/{key}` | Forward | Download a file | +| HeadObject | `HEAD /{bucket}/{key}` | Forward | Get file metadata | +| PutObject | `PUT /{bucket}/{key}` | Forward | Upload a file | +| DeleteObject | `DELETE /{bucket}/{key}` | Forward | Delete a file | +| ListBucket | `GET /{bucket}` | Response | List objects in a bucket (ListObjectsV2) | +| ListBuckets | `GET /` | Response | List all virtual buckets | +| CreateMultipartUpload | `POST /{bucket}/{key}?uploads` | NeedsBody | Initiate a multipart upload | +| UploadPart | `PUT /{bucket}/{key}?partNumber=N&uploadId=ID` | NeedsBody | Upload a part | +| CompleteMultipartUpload | `POST /{bucket}/{key}?uploadId=ID` | NeedsBody | Complete a multipart upload | +| AbortMultipartUpload | `DELETE /{bucket}/{key}?uploadId=ID` | NeedsBody | Abort a multipart upload | + +### Dispatch Types + +- **Forward** — A presigned URL is generated and returned to the runtime, which executes it with its native HTTP client. Bodies stream directly between client and backend without buffering. +- **Response** — The handler builds a complete response (XML for LIST, error responses) and returns it. No presigned URL involved. +- **NeedsBody** — The runtime collects the request body, then the handler signs and sends the request via raw HTTP (`backend.send_raw()`). Multipart only. + +## STS Operations + +| Operation | HTTP Method | Description | +|-----------|------------|-------------| +| AssumeRoleWithWebIdentity | `POST /?Action=AssumeRoleWithWebIdentity&...` | Exchange OIDC JWT for temporary credentials | + +## OIDC Discovery Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/.well-known/openid-configuration` | GET | OpenID Connect discovery document | +| `/.well-known/jwks.json` | GET | JSON Web Key Set (proxy's RSA public key) | + +These are served when `OIDC_PROVIDER_KEY` and `OIDC_PROVIDER_ISSUER` are configured. + +## Limitations + +> [!WARNING] +> - **LIST returns all results** — `object_store::list_with_delimiter()` fetches all pages internally. `IsTruncated` is always `false`. Continuation tokens and max-keys are not supported. +> - **Multipart is S3 only** — Multipart operations use raw HTTP with `S3RequestSigner` and are gated to `backend_type = "s3"`. Non-S3 backends should use single PUT requests. +> - **DeleteObject does not return confirmation** — The proxy forwards the DELETE and returns the backend's response status. diff --git a/scripts/build-push.sh b/scripts/build-push.sh deleted file mode 100755 index 92f28a2..0000000 --- a/scripts/build-push.sh +++ /dev/null @@ -1,4 +0,0 @@ -VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version') -docker buildx build --platform linux/arm64 -t 417712557820.dkr.ecr.us-west-2.amazonaws.com/source-data-proxy:v$VERSION --push . -aws ecr get-login-password --region us-west-2 --profile opendata | docker login --username AWS --password-stdin 417712557820.dkr.ecr.us-west-2.amazonaws.com -docker push 417712557820.dkr.ecr.us-west-2.amazonaws.com/source-data-proxy:v$VERSION diff --git a/scripts/deploy.sh b/scripts/deploy.sh deleted file mode 100755 index c8fc6d8..0000000 --- a/scripts/deploy.sh +++ /dev/null @@ -1,47 +0,0 @@ -VERSION=$(git tag --points-at HEAD) -SOURCE_API_URL="https://source.coop" - -# Check if the current commit is a release commit -if [ -z "$VERSION" ]; then - echo "No release tag found for this commit. Are you sure you checked out a release commit?" - exit 1; -fi - -# Check if the image for the current version exists in ECR -if [ -z "$(aws ecr describe-images --repository-name source-data-proxy --image-ids=imageTag=$VERSION --profile opendata 2> /dev/null)" ]; then - echo "Could not find image for version $VERSION in ECR. Did you build and push the image?" - exit 1; -fi - -if [ -z "${SOURCE_KEY}" ]; then - echo "The SOURCE_KEY environment variable is not set" - exit 1; -fi - -echo "Deploying $VERSION..." - -jq --arg api_url "$SOURCE_API_URL" --arg image "417712557820.dkr.ecr.us-west-2.amazonaws.com/source-data-proxy:$VERSION" --arg source_key "$SOURCE_KEY" '(.containerDefinitions[0].environment |= [{"name":"SOURCE_KEY", "value": $source_key},{"name":"SOURCE_API_URL", "value": $api_url}]) | (.containerDefinitions[0].image |= $image)' scripts/task_definition.json > scripts/task_definition_deploy.json - -# Register the task definition -if [ -z "$(aws ecs register-task-definition --cli-input-json "file://scripts/task_definition_deploy.json" --profile opendata --no-cli-auto-prompt 2> /dev/null)" ]; then - echo "Failed to create task definition" - echo "Cleaning Up..." - rm scripts/task_definition_deploy.json - exit 1; -fi - -echo "Created Task Definition" - -TASK_DEFINITION_ARN=$(aws ecs list-task-definitions --family-prefix source-data-proxy --status ACTIVE --profile opendata --query "taskDefinitionArns[-1]" --output text) - -echo "Updating Service..." - -if [ -z "$(aws ecs update-service --cluster SourceCooperative-Prod --service source-data-proxy --task-definition $TASK_DEFINITION_ARN --profile opendata 2> /dev/null)" ]; then - echo "Failed to update service" - echo "Cleaning Up..." - rm scripts/task_definition_deploy.json - exit 1; -fi - -echo "Cleaning Up..." -rm scripts/task_definition_deploy.json diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index 71b1457..0000000 --- a/scripts/run.sh +++ /dev/null @@ -1,3 +0,0 @@ -export SOURCE_KEY=foobar -export SOURCE_API_URL=http://localhost:3000 -cargo run diff --git a/scripts/tag-release.sh b/scripts/tag-release.sh deleted file mode 100755 index 84e76f2..0000000 --- a/scripts/tag-release.sh +++ /dev/null @@ -1,29 +0,0 @@ -if [[ $(git status -s) ]]; then - echo "ERROR: Please commit all of your changes before tagging the release." - exit 1 -fi - -echo "What type of bump would you like to do?" -echo "1) Patch" -echo "2) Minor" -echo "3) Major" - -read BUMP_TYPE - -if [ $BUMP_TYPE -eq 1 ]; then - cargo bump patch -elif [ $BUMP_TYPE -eq 2 ]; then - cargo bump minor -elif [ $BUMP_TYPE -eq 3 ]; then - cargo bump major -else - echo "ERROR: Invalid bump type" - exit 1 -fi - -VERSION=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[0].version') -git add Cargo.toml -git add Cargo.lock -git commit -m "Bump version to v$VERSION" -git tag -a "v$VERSION" -m "v$VERSION" -git push origin --tags diff --git a/scripts/task_definition.json b/scripts/task_definition.json deleted file mode 100644 index 6373faf..0000000 --- a/scripts/task_definition.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "family": "source-data-proxy", - "containerDefinitions": [ - { - "name": "source-data-proxy", - "image": "", - "cpu": 0, - "portMappings": [ - { - "name": "webserver", - "containerPort": 8080, - "hostPort": 8080, - "protocol": "tcp", - "appProtocol": "http" - } - ], - "essential": true, - "environment": [ - { - "name": "SOURCE_KEY", - "value": "{SOURCE_KEY_VALUE_HERE}" - }, - { - "name": "SOURCE_API_URL", - "value": "{SOURCE_API_URL}" - } - ], - "environmentFiles": [], - "mountPoints": [], - "volumesFrom": [], - "ulimits": [], - "logConfiguration": { - "logDriver": "awslogs", - "options": { - "awslogs-group": "/ecs/Source-Data-Proxy", - "mode": "non-blocking", - "awslogs-create-group": "true", - "max-buffer-size": "25m", - "awslogs-region": "us-west-2", - "awslogs-stream-prefix": "ecs" - }, - "secretOptions": [] - }, - "systemControls": [] - } - ], - "taskRoleArn": "arn:aws:iam::417712557820:role/SourceCooperative", - "executionRoleArn": "arn:aws:iam::417712557820:role/ecsTaskExecutionRole", - "networkMode": "awsvpc", - "requiresCompatibilities": ["FARGATE"], - "cpu": "4096", - "memory": "12288", - "runtimePlatform": { - "cpuArchitecture": "ARM64", - "operatingSystemFamily": "LINUX" - } -} diff --git a/src/apis/mod.rs b/src/apis/mod.rs deleted file mode 100644 index 0867ca7..0000000 --- a/src/apis/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -pub mod source; - -use crate::{backends::common::Repository, utils::auth::UserIdentity, utils::errors::BackendError}; -use async_trait::async_trait; - -pub struct Account { - pub repositories: Vec, -} - -impl Account { - fn default() -> Account { - Account { - repositories: Vec::new(), - } - } -} - -#[async_trait] -pub trait Api { - async fn get_backend_client( - &self, - account_id: &str, - repository_id: &str, - ) -> Result, BackendError>; - - async fn get_account( - &self, - account_id: String, - user_identity: UserIdentity, - ) -> Result; -} diff --git a/src/apis/source/mod.rs b/src/apis/source/mod.rs deleted file mode 100644 index 583eae5..0000000 --- a/src/apis/source/mod.rs +++ /dev/null @@ -1,691 +0,0 @@ -//! Source API client and data structures for the Source Cooperative platform. -//! -//! This module provides types and functionality for interacting with the Source API, -//! including product management, account handling, and storage backend integration. -//! -//! # Overview -//! -//! The Source Cooperative is a platform for sharing and collaborating on data products. -//! This module defines the core data structures that represent products, accounts, -//! and their associated metadata in the system. -//! -//! # Key Types -//! -//! - [`SourceProduct`] - Main product entity with metadata and configuration -//! - [`SourceProductAccount`] - Account information for product owners -//! - [`SourceProductMetadata`] - Product configuration including mirrors and roles -//! - [`SourceApi`] - API client for interacting with the Source platform -//! -//! # Examples -//! -//! ## Creating a Source API client -//! -//! ```rust -//! use source_data_proxy::apis::source::SourceApi; -//! -//! let api = SourceApi::new( -//! "https://api.source.coop".to_string(), -//! "your-api-key".to_string(), -//! None -//! ); -//! ``` -//! -//! ## Parsing product data from JSON -//! -//! ```rust -//! use serde_json; -//! use source_data_proxy::apis::source::SourceProduct; -//! -//! let json = r#"{ -//! "product_id": "example-product", -//! "account_id": "example-account", -//! "title": "Example Product", -//! "description": "An example product", -//! "created_at": "2023-01-01T00:00:00Z", -//! "updated_at": "2023-01-01T00:00:00Z", -//! "visibility": "public", -//! "disabled": false, -//! "data_mode": "open", -//! "featured": 0, -//! "metadata": { ... }, -//! "account": { ... } -//! }"#; -//! -//! let product: SourceProduct = serde_json::from_str(json)?; -//! ``` - -mod types; - -// Re-export all types -pub use types::*; - -use super::{Account, Api}; -use crate::backends::azure::AzureRepository; -use crate::backends::common::Repository; -use crate::backends::s3::S3Repository; -use crate::utils::api::process_json_response; -use crate::utils::auth::UserIdentity; -use crate::utils::errors::BackendError; -use async_trait::async_trait; -use moka::future::Cache; -use rusoto_core::Region; -use std::sync::Arc; -use std::time::Duration; - -/// Client for interacting with the Source Cooperative API. -/// -/// The `SourceApi` provides methods for managing products, accounts, and storage -/// backends. It includes built-in caching for improved performance and supports -/// both direct API calls and proxy-based requests. -/// -/// # Features -/// -/// - **Caching**: Built-in caching for products, data connections, and permissions -/// - **Multiple Storage Backends**: Support for S3, Azure, GCS, MinIO, and Ceph -/// - **Proxy Support**: Optional proxy configuration for network requests -/// - **Authentication**: API key-based authentication with user identity support -/// -/// # Examples -/// -/// ```rust -/// use source_data_proxy::apis::source::SourceApi; -/// -/// let api = SourceApi::new( -/// "https://api.source.coop".to_string(), -/// "your-api-key".to_string(), -/// None // No proxy -/// ); -/// -/// // Get a product -/// let product = api.get_repository_record("account-id", "product-id").await?; -/// ``` -#[derive(Clone)] -pub struct SourceApi { - /// Base URL for the Source API endpoint - pub endpoint: String, - - /// API key for authenticating requests - api_key: String, - - /// Cache for product data to reduce API calls - product_cache: Arc>, - - /// Cache for data connection configurations - data_connection_cache: Arc>, - - /// Cache for API key credentials - access_key_cache: Arc>, - - /// Cache for user permissions - permissions_cache: Arc>>, - - // API Client - client: reqwest::Client, -} - -#[async_trait] -impl Api for SourceApi { - /// Creates and returns a backend client for a specific repository. - /// - /// This method determines the appropriate storage backend (S3 or Azure) based on - /// the repository's configuration and returns a boxed `Repository` trait object. - /// - /// # Arguments - /// - /// * `account_id` - The ID of the account owning the repository. - /// * `repository_id` - The ID of the repository. - /// - /// # Returns - /// - /// Returns a `Result` containing either a boxed `Repository` trait object - /// or an empty error `()` if the client creation fails. - async fn get_backend_client( - &self, - account_id: &str, - repository_id: &str, - ) -> Result, BackendError> { - let product = self - .get_repository_record(account_id, repository_id) - .await?; - - let Some(repository_data) = product - .metadata - .mirrors - .get(product.metadata.primary_mirror.as_str()) - else { - return Err(BackendError::SourceRepositoryMissingPrimaryMirror); - }; - - let data_connection_id = repository_data.connection_id.clone(); - let data_connection = self.get_data_connection(&data_connection_id).await?; - - match data_connection.details.provider.as_str() { - "s3" => { - let region = - if data_connection.authentication.clone().unwrap().auth_type == "s3_local" { - Region::Custom { - name: data_connection - .details - .region - .clone() - .unwrap_or("us-west-2".to_string()), - endpoint: "http://localhost:5050".to_string(), - } - } else { - Region::Custom { - name: data_connection - .details - .region - .clone() - .unwrap_or("us-east-1".to_string()), - endpoint: format!( - "https://s3.{}.amazonaws.com", - data_connection - .details - .region - .clone() - .unwrap_or("us-east-1".to_string()) - ), - } - }; - - let bucket: String = data_connection.details.bucket.clone().unwrap_or_default(); - let base_prefix: String = data_connection - .details - .base_prefix - .clone() - .unwrap_or_default(); - - let mut prefix = format!("{}{}", base_prefix, repository_data.prefix); - if prefix.ends_with('/') { - prefix = prefix[..prefix.len() - 1].to_string(); - }; - - let auth = data_connection.authentication.clone().unwrap(); - - Ok(Box::new(S3Repository { - account_id: account_id.to_string(), - repository_id: repository_id.to_string(), - region, - bucket, - base_prefix: prefix, - auth_method: auth.auth_type, - access_key_id: auth.access_key_id, - secret_access_key: auth.secret_access_key, - })) - } - "az" => { - let account_name: String = data_connection - .details - .account_name - .clone() - .unwrap_or_default(); - - let container_name: String = data_connection - .details - .container_name - .clone() - .unwrap_or_default(); - - let base_prefix: String = data_connection - .details - .base_prefix - .clone() - .unwrap_or_default(); - - Ok(Box::new(AzureRepository { - account_id: account_id.to_string(), - repository_id: repository_id.to_string(), - account_name, - container_name, - base_prefix: format!("{}{}", base_prefix, repository_data.prefix), - })) - } - err => Err(BackendError::UnexpectedDataConnectionProvider { - provider: err.to_string(), - }), - } - } - - async fn get_account( - &self, - account_id: String, - user_identity: UserIdentity, - ) -> Result { - // Create headers - let mut headers = self.build_source_headers(); - if user_identity.api_key.is_some() { - let api_key = user_identity.api_key.unwrap(); - headers.insert( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str( - format!("{} {}", api_key.access_key_id, api_key.secret_access_key).as_str(), - ) - .unwrap(), - ); - } - - let response = self - .client - .get(format!("{}/api/v1/products/{}", self.endpoint, account_id)) - .headers(headers) - .send() - .await?; - - let product_list = - process_json_response::(response, BackendError::RepositoryNotFound) - .await?; - let mut account = Account::default(); - - for product in product_list.products { - account.repositories.push(product.product_id); - } - - Ok(account) - } -} - -impl SourceApi { - /// Creates a new Source API client with the specified configuration. - /// - /// # Arguments - /// - /// * `endpoint` - Base URL for the Source API (e.g., "https://api.source.coop") - /// * `api_key` - API key for authenticating requests - /// * `proxy_url` - Optional proxy URL for requests (e.g., "http://proxy:8080") - /// - /// # Examples - /// - /// ```rust - /// use source_data_proxy::apis::source::SourceApi; - /// - /// let api = SourceApi::new( - /// "https://api.source.coop".to_string(), - /// "your-api-key".to_string(), - /// None - /// ); - /// ``` - pub fn new(endpoint: String, api_key: String, proxy_url: Option) -> Self { - let product_cache = Arc::new( - Cache::builder() - .time_to_live(Duration::from_secs(60)) // Set TTL to 60 seconds - .build(), - ); - - let data_connection_cache = Arc::new( - Cache::builder() - .time_to_live(Duration::from_secs(60)) // Set TTL to 60 seconds - .build(), - ); - - let access_key_cache = Arc::new( - Cache::builder() - .time_to_live(Duration::from_secs(60)) // Set TTL to 60 seconds - .build(), - ); - - let permissions_cache = Arc::new( - Cache::builder() - .time_to_live(Duration::from_secs(60)) // Set TTL to 60 seconds - .build(), - ); - - let client = { - let mut client = reqwest::Client::builder() - .user_agent(concat!("source-proxy/", env!("CARGO_PKG_VERSION"))); - if let Some(proxy) = proxy_url { - client = client.proxy(reqwest::Proxy::all(proxy).unwrap()); - } - client.build().unwrap() - }; - - SourceApi { - endpoint, - api_key, - product_cache, - data_connection_cache, - access_key_cache, - permissions_cache, - client, - } - } - - /// Builds the headers for the Source API. - /// - /// # Returns - /// - /// Returns a `reqwest::header::HeaderMap` with the appropriate headers. - fn build_source_headers(&self) -> reqwest::header::HeaderMap { - const CORE_REQUEST_HEADERS: &[(&str, &str)] = &[("accept", "application/json")]; - CORE_REQUEST_HEADERS - .iter() - .map(|(name, value)| { - ( - reqwest::header::HeaderName::from_lowercase(name.as_bytes()).unwrap(), - reqwest::header::HeaderValue::from_str(value).unwrap(), - ) - }) - .collect() - } - - /// Retrieves a product record by account and product ID. - /// - /// This method fetches product information from the Source API, including - /// metadata, account details, and configuration. Results are cached for - /// improved performance. - /// - /// # Arguments - /// - /// * `account_id` - The ID of the account that owns the product - /// * `repository_id` - The ID of the product to retrieve - /// - /// # Returns - /// - /// Returns a `Result` containing either a `SourceProduct` struct with the - /// product information or a `BackendError` if the request fails. - /// - /// # Examples - /// - /// ```rust - /// use source_data_proxy::apis::source::SourceApi; - /// - /// let api = SourceApi::new( - /// "https://api.source.coop".to_string(), - /// "your-api-key".to_string(), - /// None - /// ); - /// - /// let product = api.get_repository_record("example-account", "example-product").await?; - /// println!("Product: {}", product.title); - /// ``` - pub async fn get_repository_record( - &self, - account_id: &str, - repository_id: &str, - ) -> Result { - // Try to get the cached value - let cache_key = format!("{account_id}/{repository_id}"); - - if let Some(cached_repo) = self.product_cache.get(&cache_key).await { - return Ok(cached_repo); - } - - // If not in cache, fetch it - let url = format!( - "{}/api/v1/products/{}/{}", - self.endpoint, account_id, repository_id - ); - let headers = self.build_source_headers(); - let response = self.client.get(url).headers(headers).send().await?; - let repository = - process_json_response::(response, BackendError::RepositoryNotFound) - .await?; - - // Cache the successful result - self.product_cache - .insert(cache_key, repository.clone()) - .await; - Ok(repository) - } - - async fn fetch_data_connection( - &self, - data_connection_id: &str, - ) -> Result { - let mut headers = self.build_source_headers(); - headers.insert( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str(&self.api_key).unwrap(), - ); - - let response = self - .client - .get(format!( - "{}/api/v1/data-connections/{}", - self.endpoint, data_connection_id - )) - .headers(headers) - .send() - .await?; - process_json_response::(response, BackendError::DataConnectionNotFound) - .await - } - - async fn get_data_connection( - &self, - data_connection_id: &str, - ) -> Result { - if let Some(cached_repo) = self.data_connection_cache.get(data_connection_id).await { - return Ok(cached_repo); - } - - // If not in cache, fetch it - match self.fetch_data_connection(data_connection_id).await { - Ok(data_connection) => { - // Cache the successful result - self.data_connection_cache - .insert(data_connection_id.to_string(), data_connection.clone()) - .await; - Ok(data_connection) - } - Err(e) => Err(e), - } - } - - pub async fn get_api_key(&self, access_key_id: &str) -> Result { - if let Some(cached_secret) = self.access_key_cache.get(access_key_id).await { - return Ok(cached_secret); - } - - // If not in cache, fetch it - if access_key_id.is_empty() { - let secret = APIKey { - access_key_id: "".to_string(), - secret_access_key: "".to_string(), - }; - self.access_key_cache - .insert(access_key_id.to_string(), secret.clone()) - .await; - Ok(secret) - } else { - let secret = self.fetch_api_key(access_key_id.to_string()).await?; - self.access_key_cache - .insert(access_key_id.to_string(), secret.clone()) - .await; - Ok(secret) - } - } - - async fn fetch_api_key(&self, access_key_id: String) -> Result { - // Create headers - let mut headers = self.build_source_headers(); - headers.insert( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str(&self.api_key).unwrap(), - ); - let response = self - .client - .get(format!( - "{}/api/v1/api-keys/{access_key_id}/auth", - self.endpoint - )) - .headers(headers) - .send() - .await?; - let key = process_json_response::(response, BackendError::ApiKeyNotFound).await?; - - Ok(APIKey { - access_key_id, - secret_access_key: key.secret_access_key, - }) - } - - pub async fn is_authorized( - &self, - user_identity: UserIdentity, - account_id: &str, - repository_id: &str, - permission: RepositoryPermission, - ) -> Result { - let anon: bool = user_identity.api_key.is_none(); - - // Try to get the cached value - let cache_key = if anon { - format!("{account_id}/{repository_id}") - } else { - let api_key = user_identity.clone().api_key.unwrap(); - format!("{}/{}/{}", account_id, repository_id, api_key.access_key_id) - }; - - if let Some(cache_permissions) = self.permissions_cache.get(&cache_key).await { - return Ok(cache_permissions.contains(&permission)); - } - - // If not in cache, fetch it - let permissions = self - .fetch_permission(user_identity.clone(), account_id, repository_id) - .await?; - - // Cache the successful result - self.permissions_cache - .insert(cache_key, permissions.clone()) - .await; - - Ok(permissions.contains(&permission)) - } - - pub async fn assert_authorized( - &self, - user_identity: UserIdentity, - account_id: &str, - repository_id: &str, - permission: RepositoryPermission, - ) -> Result { - let authorized = self - .is_authorized(user_identity, account_id, repository_id, permission) - .await?; - if !authorized { - return Err(BackendError::UnauthorizedError); - } - Ok(authorized) - } - - async fn fetch_permission( - &self, - user_identity: UserIdentity, - account_id: &str, - repository_id: &str, - ) -> Result, BackendError> { - // Create headers - let mut headers = self.build_source_headers(); - if user_identity.api_key.is_some() { - let api_key = user_identity.api_key.unwrap(); - headers.insert( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str( - format!("{} {}", api_key.access_key_id, api_key.secret_access_key).as_str(), - ) - .unwrap(), - ); - } - - let response = self - .client - .get(format!( - "{}/api/v1/products/{account_id}/{repository_id}/permissions", - self.endpoint - )) - .headers(headers) - .send() - .await?; - - process_json_response::>( - response, - BackendError::RepositoryPermissionsNotFound, - ) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json; - - #[test] - fn test_json_parsing() { - let json_str = r#" - { - "updated_at": "2023-01-15T10:30:00.000Z", - "metadata": { - "primary_mirror": "aws-us-east-1", - "mirrors": { - "aws-us-east-1": { - "storage_type": "s3", - "is_primary": true, - "connection_id": "aws-connection-123", - "config": { "region": "us-east-1", "bucket": "example-bucket" }, - "prefix": "example-account/sample-product/" - } - }, - "tags": ["example", "test"], - "roles": { - "example-account": { - "granted_at": "2023-01-15T10:30:00.000Z", - "account_id": "example-account", - "role": "admin", - "granted_by": "example-account" - } - } - }, - "created_at": "2023-01-01T00:00:00.000Z", - "disabled": false, - "visibility": "public", - "data_mode": "open", - "account_id": "example-account", - "description": "An example product for testing purposes.", - "product_id": "sample-product", - "featured": 0, - "title": "Sample Product", - "account": { - "identity_id": "12345678-1234-1234-1234-123456789abc", - "metadata_public": { - "domains": [ - { - "created_at": "2023-01-10T12:00:00.000Z", - "domain": "example.com", - "status": "unverified" - } - ], - "location": "Example City" - }, - "updated_at": "2023-01-15T10:30:00.000Z", - "flags": ["create_repositories", "create_organizations"], - "created_at": "2023-01-01T00:00:00.000Z", - "emails": [ - { - "verified": false, - "added_at": "2023-01-01T00:00:00.000Z", - "address": "user@example.com", - "is_primary": true - } - ], - "disabled": false, - "metadata_private": {}, - "account_id": "example-account", - "name": "Example User", - "type": "individual" - } - } - "#; - - match serde_json::from_str::(json_str) { - Ok(_product) => { - println!("✅ JSON parsed successfully!"); - } - Err(e) => { - panic!("❌ JSON parsing failed: {}", e); - } - } - } -} diff --git a/src/apis/source/types.rs b/src/apis/source/types.rs deleted file mode 100644 index 3126b4b..0000000 --- a/src/apis/source/types.rs +++ /dev/null @@ -1,437 +0,0 @@ -//! Data structures and types for the Source Cooperative API. -//! -//! This module contains all the data types, enums, and structures used to interact -//! with the Source Cooperative platform, including products, accounts, permissions, -//! and storage configurations. - -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; - -/// Repository access permissions for products. -/// -/// Defines the level of access a user or account has to a specific product. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum RepositoryPermission { - /// Read-only access to the product data - #[serde(rename = "read")] - Read, - /// Read and write access to the product data - #[serde(rename = "write")] - Write, -} - -/// Product visibility levels that control who can discover and access the product. -/// -/// This determines how the product appears in listings and search results. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ProductVisibility { - /// Product is visible to everyone and appears in public listings - #[serde(rename = "public")] - Public, - /// Product is not listed publicly but can be accessed with direct link - #[serde(rename = "unlisted")] - Unlisted, - /// Product access is restricted to specific users or groups - #[serde(rename = "restricted")] - Restricted, -} - -/// Data access modes that define how users can access the product's data. -/// -/// This controls the business model and access patterns for the product. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum ProductDataMode { - /// Data is freely accessible to anyone - #[serde(rename = "open")] - Open, - /// Data requires a subscription to access - #[serde(rename = "subscription")] - Subscription, - /// Data is private and only accessible to authorized users - #[serde(rename = "private")] - Private, -} - -/// Supported storage backend types for product data mirrors. -/// -/// Each product can have multiple mirrors across different storage providers -/// for redundancy and performance optimization. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum StorageType { - /// Amazon S3 compatible storage - #[serde(rename = "s3")] - S3, - /// Microsoft Azure Blob Storage - #[serde(rename = "azure")] - Azure, - /// Google Cloud Storage - #[serde(rename = "gcs")] - Gcs, - /// MinIO object storage - #[serde(rename = "minio")] - Minio, - /// Ceph distributed storage - #[serde(rename = "ceph")] - Ceph, -} - -/// Account types in the Source Cooperative system. -/// -/// Different account types have different capabilities and metadata structures. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum AccountType { - /// Individual user account - #[serde(rename = "individual")] - Individual, - /// Organization or group account - #[serde(rename = "organization")] - Organization, -} - -/// Domain verification status for account domains. -/// -/// Used to track the verification state of custom domains associated with accounts. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum DomainStatus { - /// Domain has not been verified - #[serde(rename = "unverified")] - Unverified, - /// Domain verification is in progress - #[serde(rename = "pending")] - Pending, - /// Domain has been successfully verified - #[serde(rename = "verified")] - Verified, -} - -/// Methods available for domain verification. -/// -/// Different verification methods provide different levels of security and ease of use. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub enum VerificationMethod { - /// DNS-based verification using TXT records - #[serde(rename = "dns")] - Dns, - /// HTML-based verification using meta tags - #[serde(rename = "html")] - Html, - /// File-based verification using uploaded files - #[serde(rename = "file")] - File, -} - -/// API key credentials for authenticating with the Source API. -/// -/// Contains the access key ID and secret access key used for API authentication. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct APIKey { - /// The access key ID for API authentication - pub access_key_id: String, - /// The secret access key for API authentication - pub secret_access_key: String, -} - -/// Represents a product in the Source Cooperative system. -/// -/// A product is the main entity that contains data and metadata, similar to a repository -/// in traditional version control systems. Products can have multiple storage mirrors -/// for redundancy and performance optimization. -/// -/// # Examples -/// -/// ```rust -/// use serde_json; -/// -/// let json = r#"{ -/// "product_id": "example-product", -/// "account_id": "example-account", -/// "title": "Example Product", -/// "description": "An example product", -/// "created_at": "2023-01-01T00:00:00Z", -/// "updated_at": "2023-01-01T00:00:00Z", -/// "visibility": "public", -/// "disabled": false, -/// "data_mode": "open", -/// "featured": 0, -/// "metadata": { ... }, -/// "account": { ... } -/// }"#; -/// let product: SourceProduct = serde_json::from_str(json)?; -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProduct { - /// Unique identifier for the product (3-40 chars, lowercase, alphanumeric with hyphens) - pub product_id: String, - - /// ID of the account that owns this product - pub account_id: String, - - /// Human-readable title of the product - pub title: String, - - /// Detailed description of the product - pub description: String, - - /// ISO 8601 timestamp when the product was created - pub created_at: String, - - /// ISO 8601 timestamp when the product was last updated - pub updated_at: String, - - /// Visibility level of the product - pub visibility: ProductVisibility, - - /// Whether the product is disabled - pub disabled: bool, - - /// Data access mode for the product - pub data_mode: ProductDataMode, - - /// Featured status (0 = not featured, 1 = featured) - pub featured: i32, - - /// Product metadata including mirrors, tags, and roles - pub metadata: SourceProductMetadata, - - /// Optional account information - pub account: Option, -} - -/// Metadata for a product including mirrors, tags, and roles. -/// -/// Contains all the configuration and organizational information for a product -/// that doesn't fit into the main product fields. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductMetadata { - /// Map of mirror names to mirror configurations - pub mirrors: HashMap, - - /// Name of the primary mirror (key in the mirrors map) - pub primary_mirror: String, - - /// Optional list of tags associated with the product - pub tags: Option>, -} - -/// Configuration for a storage mirror of a product. -/// -/// Each product can have multiple mirrors across different storage providers -/// for redundancy and performance optimization. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductMirror { - /// Type of storage backend used for this mirror - pub storage_type: StorageType, - - /// ID of the data connection configuration - pub connection_id: String, - - /// Storage prefix/path for this mirror - pub prefix: String, - - /// Storage-specific configuration options - pub config: SourceProductMirrorConfig, - - /// Whether this is the primary mirror for the product - pub is_primary: bool, -} - -/// Storage-specific configuration options for a mirror. -/// -/// Different storage backends require different configuration parameters. -/// All fields are optional and only relevant for specific storage types. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductMirrorConfig { - /// AWS region for S3/GCS storage - pub region: Option, - - /// Bucket name for S3/GCS storage - pub bucket: Option, - - /// Container name for Azure Blob Storage - pub container: Option, - - /// Custom endpoint URL for MinIO/Ceph storage - pub endpoint: Option, -} - -/// Account information associated with a product. -/// -/// Contains the account details of the product owner, including profile information, -/// contact details, and organizational metadata. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductAccount { - /// Unique identifier for the account - pub account_id: String, - - /// Type of account (individual or organization) - #[serde(rename = "type")] - pub account_type: AccountType, - - /// Display name of the account - pub name: String, - - /// Identity provider ID (only for individual accounts) - pub identity_id: Option, - - /// Public metadata visible to other users - pub metadata_public: SourceProductAccountMetadataPublic, - - /// Email addresses associated with the account - pub emails: Option>, - - /// ISO 8601 timestamp when the account was created - pub created_at: String, - - /// ISO 8601 timestamp when the account was last updated - pub updated_at: String, - - /// Whether the account is disabled - pub disabled: bool, - - /// Account capability flags - pub flags: Vec, - - /// Private metadata not visible to other users - pub metadata_private: Option>, -} - -/// Domain verification information for an account. -/// -/// Tracks the verification status and process for custom domains associated with accounts. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccountDomain { - /// The domain name being verified - pub domain: String, - - /// Current verification status of the domain - pub status: DomainStatus, - - /// Method used for verification (if applicable) - pub verification_method: Option, - - /// Token used for verification (if applicable) - pub verification_token: Option, - - /// ISO 8601 timestamp when verification was completed - pub verified_at: Option, - - /// ISO 8601 timestamp when domain was added - pub created_at: String, - - /// ISO 8601 timestamp when verification expires (if applicable) - pub expires_at: Option, -} - -/// Email address information for an account. -/// -/// Tracks email addresses associated with an account, including verification status -/// and primary email designation. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceAccountEmail { - /// The email address - pub address: String, - - /// Whether the email address has been verified - pub verified: bool, - - /// ISO 8601 timestamp when verification was completed - pub verified_at: Option, - - /// Whether this is the primary email address for the account - pub is_primary: bool, - - /// ISO 8601 timestamp when the email was added - pub added_at: String, -} - -/// Public metadata for an account. -/// -/// Information that is visible to other users and can be displayed in public profiles. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductAccountMetadataPublic { - /// Optional biographical information - pub bio: Option, - - /// Verified domains associated with the account - pub domains: Option>, - - /// Geographic location of the account holder - pub location: Option, - - /// Owner account ID (for organizational accounts) - pub owner_account_id: Option, - - /// List of admin account IDs (for organizational accounts) - pub admin_account_ids: Option>, - - /// List of member account IDs (for organizational accounts) - pub member_account_ids: Option>, -} - -/// Details about a data connection configuration. -/// -/// Contains provider-specific information about how to connect to storage backends. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataConnectionDetails { - /// Storage provider type (e.g., "s3", "az") - pub provider: String, - /// Cloud region for the storage service - pub region: Option, - /// Base prefix for all data stored through this connection - pub base_prefix: Option, - /// S3 bucket name (for S3-compatible providers) - pub bucket: Option, - /// Azure storage account name (for Azure) - pub account_name: Option, - /// Azure container name (for Azure) - pub container_name: Option, -} - -/// Authentication configuration for a data connection. -/// -/// Defines how to authenticate with the storage backend. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataConnectionAuthentication { - /// Type of authentication (e.g., "s3_local", "iam_role") - #[serde(rename = "type")] - pub auth_type: String, - /// Access key ID for credential-based authentication - pub access_key_id: Option, - /// Secret access key for credential-based authentication - pub secret_access_key: Option, -} - -/// Configuration for connecting to external data storage. -/// -/// A data connection defines how products can access external storage backends -/// like S3, Azure Blob Storage, or other object storage systems. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DataConnection { - /// Unique identifier for this data connection - pub data_connection_id: String, - /// Human-readable name for the connection - pub name: String, - /// Template for generating storage prefixes - pub prefix_template: String, - /// Whether this connection only allows read operations - pub read_only: bool, - /// List of data modes that can use this connection - pub allowed_data_modes: Vec, - /// Optional flag required on accounts to use this connection - pub required_flag: Option, - /// Provider-specific connection details - pub details: DataConnectionDetails, - /// Authentication configuration for the connection - pub authentication: Option, -} - -/// List of products with pagination support. -/// -/// Used for API responses that return multiple products. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SourceProductList { - /// List of products in this page - pub products: Vec, - /// Token for fetching the next page of results - pub next: Option, -} diff --git a/src/backends/azure.rs b/src/backends/azure.rs deleted file mode 100644 index 180d5bc..0000000 --- a/src/backends/azure.rs +++ /dev/null @@ -1,305 +0,0 @@ -use actix_web::http::header::RANGE; -use async_trait::async_trait; -use azure_core::request_options::NextMarker; -use azure_storage::StorageCredentials; -use azure_storage_blobs::container::operations::list_blobs::BlobItem; -use azure_storage_blobs::prelude::*; -use bytes::Bytes; -use core::num::NonZeroU32; -use futures::StreamExt; -use futures_core::Stream; -use reqwest; -use std::pin::Pin; -use time::format_description::well_known::{Rfc2822, Rfc3339}; - -use crate::backends::common::{ - CommonPrefix, CompleteMultipartUploadResponse, Content, CreateMultipartUploadResponse, - GetObjectResponse, HeadObjectResponse, ListBucketResult, Repository, -}; -use crate::utils::core::replace_first; -use crate::utils::errors::BackendError; - -use super::common::{MultipartPart, UploadPartResponse}; - -pub struct AzureRepository { - pub account_id: String, - pub repository_id: String, - pub account_name: String, - pub container_name: String, - pub base_prefix: String, -} - -use chrono::format::strftime::StrftimeItems; -use chrono::{DateTime, FixedOffset}; - -fn rfc2822_to_rfc7231(rfc2822_date: &str) -> Result { - // Parse the RFC2822 date string - let datetime = DateTime::parse_from_rfc2822(rfc2822_date)?; - - // Define the format string for RFC7231 - let format = StrftimeItems::new("%a, %d %b %Y %H:%M:%S GMT"); - - // Convert to UTC and format as RFC7231 - Ok(datetime - .with_timezone(&FixedOffset::east_opt(0).unwrap()) - .format_with_items(format.clone()) - .to_string()) -} - -#[async_trait] -impl Repository for AzureRepository { - async fn get_object( - &self, - key: String, - range: Option, - ) -> Result { - let credentials = StorageCredentials::anonymous(); - - let client = BlobServiceClient::new(self.account_name.to_string(), credentials) - .container_client(&self.container_name); - - let blob_client = client.blob_client(format!( - "{}/{}", - self.base_prefix.trim_end_matches('/'), - key - )); - - let blob = blob_client.get_properties().await?; - let content_type = blob.blob.properties.content_type.to_string(); - let etag = blob.blob.properties.etag.to_string(); - let last_modified = rfc2822_to_rfc7231( - blob.blob - .properties - .last_modified - .format(&Rfc2822) - .unwrap_or_else(|_| String::from("Invalid DateTime")) - .as_str(), - ) - .unwrap_or_else(|_| String::from("Invalid DateTime")); - - let client = reqwest::Client::new(); - - // Start building the request - let mut request = client.get(format!( - "https://{}.blob.core.windows.net/{}/{}/{}", - self.account_name, - self.container_name, - self.base_prefix.trim_end_matches('/'), - key - )); - - // If a range is provided, add it to the headers - if let Some(range_value) = range { - request = request.header(RANGE, range_value); - } - - // Send the request and await the response - let response = request.send().await?; - // Check if the status code is successful - if !response.status().is_success() { - return Err(BackendError::UnexpectedApiError(response.text().await?)); - } - - // Get the byte stream from the response - let content_length = response.content_length(); - let stream = response.bytes_stream(); - let boxed_stream: Pin> + Send>> = - Box::pin(stream); - - Ok(GetObjectResponse { - content_length: content_length.unwrap_or(0), - content_type, - etag, - last_modified, - body: boxed_stream, - }) - } - - async fn delete_object(&self, _key: String) -> Result<(), BackendError> { - Err(BackendError::UnsupportedOperation( - "Delete object is not supported on Azure".to_string(), - )) - } - - async fn create_multipart_upload( - &self, - _key: String, - _content_type: Option, - ) -> Result { - Err(BackendError::UnsupportedOperation( - "Create multipart upload is not supported on Azure".to_string(), - )) - } - - async fn abort_multipart_upload( - &self, - _key: String, - _upload_id: String, - ) -> Result<(), BackendError> { - Err(BackendError::UnsupportedOperation( - "Abort multipart upload is not supported on Azure".to_string(), - )) - } - - async fn complete_multipart_upload( - &self, - _key: String, - _upload_id: String, - _parts: Vec, - ) -> Result { - Err(BackendError::UnsupportedOperation( - "Complete multipart upload is not supported on Azure".to_string(), - )) - } - - async fn upload_multipart_part( - &self, - _key: String, - _upload_id: String, - _part_number: String, - _bytes: Bytes, - ) -> Result { - Err(BackendError::UnsupportedOperation( - "Upload multipart part is not supported on Azure".to_string(), - )) - } - - async fn put_object( - &self, - _key: String, - _bytes: Bytes, - _content_type: Option, - ) -> Result<(), BackendError> { - Err(BackendError::UnsupportedOperation( - "Put object is not supported on Azure".to_string(), - )) - } - - async fn head_object(&self, key: String) -> Result { - let credentials = StorageCredentials::anonymous(); - - // Create a client for anonymous access - let client = BlobServiceClient::new(self.account_name.to_string(), credentials) - .container_client(&self.container_name); - - let blob = client - .blob_client(format!( - "{}/{}", - self.base_prefix.trim_end_matches('/'), - key - )) - .get_properties() - .await?; - - Ok(HeadObjectResponse { - content_length: blob.blob.properties.content_length, - content_type: blob.blob.properties.content_type.to_string(), - etag: blob.blob.properties.etag.to_string(), - last_modified: rfc2822_to_rfc7231( - blob.blob - .properties - .last_modified - .format(&Rfc2822) - .unwrap_or_else(|_| String::from("Invalid DateTime")) - .as_str(), - ) - .unwrap_or_else(|_| String::from("Invalid DateTime")), - }) - } - - async fn list_objects_v2( - &self, - prefix: String, - continuation_token: Option, - delimiter: Option, - max_keys: NonZeroU32, - ) -> Result { - let mut result = ListBucketResult { - name: self.account_id.to_string(), - prefix: prefix.clone(), - key_count: 0, - max_keys: 0, - is_truncated: false, - contents: vec![], - common_prefixes: vec![], - next_continuation_token: None, - }; - - let credentials = StorageCredentials::anonymous(); - - // Create a client for anonymous access - let client = BlobServiceClient::new(self.account_name.to_string(), credentials) - .container_client(&self.container_name); - let search_prefix = format!("{}/{}", self.base_prefix.trim_end_matches('/'), prefix); - - let next_marker = continuation_token.map_or(NextMarker::new("".to_string()), Into::into); - - let query_delmiter = delimiter.unwrap_or_default(); - - // List blobs - let mut stream = client - .list_blobs() - .marker(next_marker) - .prefix(search_prefix) - .max_results(max_keys) - .delimiter(query_delmiter) - .into_stream(); - - if let Some(Ok(blob)) = stream.next().await { - if blob.max_results.is_some() { - result.max_keys = blob.max_results.unwrap() as i64; - } - - if blob.next_marker.is_some() { - result.is_truncated = true; - result.next_continuation_token = Some( - blob.next_marker - .unwrap_or(NextMarker::new("".to_string())) - .as_str() - .to_string(), - ); - } - - for blob_item in blob.blobs.items { - match blob_item { - BlobItem::Blob(b) => { - result.contents.push(Content { - key: replace_first( - b.name, - self.base_prefix.clone().trim_end_matches('/').to_string(), - self.repository_id.to_string(), - ), - last_modified: b - .properties - .last_modified - .format(&Rfc3339) - .unwrap_or_else(|_| String::from("Invalid DateTime")), - etag: b.properties.etag.to_string(), - size: b.properties.content_length as i64, - storage_class: b.properties.blob_type.to_string(), - }); - } - BlobItem::BlobPrefix(bp) => { - result.common_prefixes.push(CommonPrefix { - prefix: replace_first( - bp.name, - self.base_prefix.clone().trim_end_matches('/').to_string(), - self.repository_id.to_string(), - ), - }); - } - } - } - } - - Ok(result) - } - async fn copy_object( - &self, - _copy_identifier_path: String, - _key: String, - _range: Option, - ) -> Result<(), BackendError> { - Ok(()) - } -} diff --git a/src/backends/common.rs b/src/backends/common.rs deleted file mode 100644 index 703db50..0000000 --- a/src/backends/common.rs +++ /dev/null @@ -1,170 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use core::num::NonZeroU32; -use futures_core::Stream; -use serde::Deserialize; -use serde::Serialize; -use std::pin::Pin; - -use reqwest::Error as ReqwestError; -type BoxedReqwestStream = Pin> + Send>>; -use crate::utils::errors::BackendError; - -pub struct GetObjectResponse { - pub content_length: u64, - pub content_type: String, - pub last_modified: String, - pub etag: String, - pub body: BoxedReqwestStream, -} - -pub struct HeadObjectResponse { - pub content_length: u64, - pub content_type: String, - pub last_modified: String, - pub etag: String, -} - -#[derive(Debug, Serialize)] -pub struct CompleteMultipartUploadResponse { - #[serde(rename = "Location")] - pub location: String, - #[serde(rename = "Bucket")] - pub bucket: String, - #[serde(rename = "Key")] - pub key: String, - #[serde(rename = "ETag")] - pub etag: String, -} - -#[async_trait] -pub trait Repository { - async fn delete_object(&self, key: String) -> Result<(), BackendError>; - async fn create_multipart_upload( - &self, - key: String, - content_type: Option, - ) -> Result; - async fn abort_multipart_upload( - &self, - key: String, - upload_id: String, - ) -> Result<(), BackendError>; - async fn complete_multipart_upload( - &self, - key: String, - upload_id: String, - parts: Vec, - ) -> Result; - async fn upload_multipart_part( - &self, - key: String, - upload_id: String, - part_number: String, - bytes: Bytes, - ) -> Result; - async fn put_object( - &self, - key: String, - bytes: Bytes, - content_type: Option, - ) -> Result<(), BackendError>; - async fn get_object( - &self, - key: String, - range: Option, - ) -> Result; - async fn head_object(&self, key: String) -> Result; - async fn list_objects_v2( - &self, - prefix: String, - continuation_token: Option, - delimiter: Option, - max_keys: NonZeroU32, - ) -> Result; - async fn copy_object( - &self, - copy_identifier_path: String, - key: String, - range: Option, - ) -> Result<(), BackendError>; -} - -#[derive(Debug, Serialize)] -pub struct ListBucketResult { - #[serde(rename = "Name")] - pub name: String, - #[serde(rename = "Prefix")] - pub prefix: String, - #[serde(rename = "KeyCount")] - pub key_count: i64, - #[serde(rename = "MaxKeys")] - pub max_keys: i64, - #[serde(rename = "IsTruncated")] - pub is_truncated: bool, - #[serde(rename = "Contents")] - pub contents: Vec, - #[serde(rename = "CommonPrefixes")] - pub common_prefixes: Vec, - #[serde(rename = "NextContinuationToken")] - pub next_continuation_token: Option, -} - -#[derive(Debug, Serialize)] -pub struct Content { - #[serde(rename = "Key")] - pub key: String, - #[serde(rename = "LastModified")] - pub last_modified: String, - #[serde(rename = "ETag")] - pub etag: String, - #[serde(rename = "Size")] - pub size: i64, - #[serde(rename = "StorageClass")] - pub storage_class: String, -} - -#[derive(Debug, Serialize)] -pub struct CommonPrefix { - #[serde(rename = "Prefix")] - pub prefix: String, -} - -#[derive(Debug, Serialize)] -pub struct CreateMultipartUploadResponse { - #[serde(rename = "Bucket")] - pub bucket: String, - #[serde(rename = "Key")] - pub key: String, - #[serde(rename = "UploadId")] - pub upload_id: String, -} - -#[derive(Debug, Serialize)] -pub struct UploadPartResponse { - #[serde(rename = "ETag")] - pub etag: String, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct MultipartPart { - #[serde(rename = "PartNumber")] - pub part_number: i64, - #[serde(rename = "ETag")] - pub etag: String, - #[serde(rename = "ChecksumCRC32")] - pub checksum_crc32: Option, - #[serde(rename = "ChecksumCRC32C")] - pub checksum_crc32c: Option, - #[serde(rename = "ChecksumSHA1")] - pub checksum_sha1: Option, - #[serde(rename = "ChecksumSHA256")] - pub checksum_sha256: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename = "CompleteMultipartUpload")] -pub struct CompleteMultipartUpload { - #[serde(rename = "Part")] - pub parts: Vec, -} diff --git a/src/backends/mod.rs b/src/backends/mod.rs deleted file mode 100644 index 0fb961c..0000000 --- a/src/backends/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod azure; -pub mod common; -pub mod s3; diff --git a/src/backends/s3.rs b/src/backends/s3.rs deleted file mode 100644 index ec758ad..0000000 --- a/src/backends/s3.rs +++ /dev/null @@ -1,382 +0,0 @@ -use super::common::{MultipartPart, UploadPartResponse}; -use crate::backends::common::{ - CommonPrefix, CompleteMultipartUploadResponse, Content, CreateMultipartUploadResponse, - GetObjectResponse, HeadObjectResponse, ListBucketResult, Repository, -}; -use crate::utils::core::replace_first; -use crate::utils::errors::BackendError; -use actix_web::http::header::RANGE; -use async_trait::async_trait; -use bytes::Bytes; -use chrono::Utc; -use core::num::NonZeroU32; -use futures_core::Stream; -use reqwest; -use rusoto_core::Region; -use rusoto_s3::{ - AbortMultipartUploadRequest, CompleteMultipartUploadRequest, CompletedMultipartUpload, - CompletedPart, CreateMultipartUploadRequest, DeleteObjectRequest, HeadObjectRequest, - ListObjectsV2Request, PutObjectRequest, S3Client, UploadPartRequest, S3, -}; -use std::pin::Pin; - -pub struct S3Repository { - pub account_id: String, - pub repository_id: String, - pub region: Region, - pub bucket: String, - pub base_prefix: String, - pub auth_method: String, - pub access_key_id: Option, - pub secret_access_key: Option, -} - -impl S3Repository { - fn create_client(&self) -> Result { - if self.auth_method == "s3_access_key" { - let credentials = rusoto_credential::StaticProvider::new_minimal( - self.access_key_id.clone().unwrap(), - self.secret_access_key.clone().unwrap(), - ); - Ok(S3Client::new_with( - rusoto_core::request::HttpClient::new().unwrap(), - credentials, - self.region.clone(), - )) - } else if self.auth_method == "s3_ecs_task_role" { - let credentials = rusoto_credential::ContainerProvider::new(); - Ok(S3Client::new_with( - rusoto_core::request::HttpClient::new().unwrap(), - credentials, - self.region.clone(), - )) - } else if self.auth_method == "s3_local" { - let credentials = rusoto_credential::ChainProvider::new(); - Ok(S3Client::new_with( - rusoto_core::request::HttpClient::new().unwrap(), - credentials, - self.region.clone(), - )) - } else { - Err(BackendError::UnsupportedAuthMethod(format!( - "Unsupported auth method: {}", - self.auth_method - ))) - } - } -} - -#[async_trait] -impl Repository for S3Repository { - async fn get_object( - &self, - key: String, - range: Option, - ) -> Result { - let head_object_response = self.head_object(key.clone()).await?; - let client = reqwest::Client::new(); - - let url = if self.auth_method == "s3_local" { - format!( - "http://localhost:5050/{}/{}/{}", - self.bucket, self.base_prefix, key - ) - } else { - format!( - "https://s3.{}.amazonaws.com/{}/{}/{}", - self.region.name(), - self.bucket, - self.base_prefix, - key - ) - }; - // Start building the request - let mut request = client.get(url); - - // If a range is provided, add it to the headers - if let Some(range_value) = range { - request = request.header(RANGE, range_value); - } - - // Send the request and await the response - let response = request.send().await?; - // Get the byte stream from the response - let content_length = response.content_length(); - let stream = response.bytes_stream(); - let boxed_stream: Pin> + Send>> = - Box::pin(stream); - - Ok(GetObjectResponse { - content_length: content_length.unwrap_or(0), - content_type: head_object_response.content_type, - etag: head_object_response.etag, - last_modified: head_object_response.last_modified, - body: boxed_stream, - }) - } - - async fn put_object( - &self, - key: String, - bytes: Bytes, - content_type: Option, - ) -> Result<(), BackendError> { - let client = self.create_client()?; - - let request = PutObjectRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - body: Some(bytes.to_vec().into()), - content_type, - ..Default::default() - }; - - client.put_object(request).await?; - Ok(()) - } - - async fn create_multipart_upload( - &self, - key: String, - content_type: Option, - ) -> Result { - let client = self.create_client()?; - - let request = CreateMultipartUploadRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - content_type, - ..Default::default() - }; - - let result = client.create_multipart_upload(request).await?; - Ok(CreateMultipartUploadResponse { - bucket: self.account_id.clone(), - key: key.clone(), - upload_id: result.upload_id.unwrap(), - }) - } - - async fn abort_multipart_upload( - &self, - key: String, - upload_id: String, - ) -> Result<(), BackendError> { - let client = self.create_client()?; - - let request = AbortMultipartUploadRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - upload_id, - ..Default::default() - }; - - client.abort_multipart_upload(request).await?; - Ok(()) - } - - async fn complete_multipart_upload( - &self, - key: String, - upload_id: String, - parts: Vec, - ) -> Result { - let client = self.create_client()?; - - let request = CompleteMultipartUploadRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - upload_id, - multipart_upload: Some(CompletedMultipartUpload { - parts: Some( - parts - .iter() - .map(|part| CompletedPart { - e_tag: Some(part.etag.clone()), - part_number: Some(part.part_number), - }) - .collect(), - ), - }), - ..Default::default() - }; - - let result = client.complete_multipart_upload(request).await?; - Ok(CompleteMultipartUploadResponse { - location: "".to_string(), - bucket: self.account_id.clone(), - key: key.clone(), - etag: result.e_tag.unwrap(), - }) - } - - async fn upload_multipart_part( - &self, - key: String, - upload_id: String, - part_number: String, - bytes: Bytes, - ) -> Result { - let client = self.create_client()?; - - let request = UploadPartRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - upload_id, - part_number: part_number.parse().unwrap(), - body: Some(bytes.to_vec().into()), - ..Default::default() - }; - - let result = client.upload_part(request).await?; - Ok(UploadPartResponse { - etag: result.e_tag.unwrap(), - }) - } - - async fn delete_object(&self, key: String) -> Result<(), BackendError> { - let client = self.create_client()?; - - let request = DeleteObjectRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - ..Default::default() - }; - - client.delete_object(request).await?; - Ok(()) - } - - async fn head_object(&self, key: String) -> Result { - let client = self.create_client()?; - - let request = HeadObjectRequest { - bucket: self.bucket.clone(), - key: format!("{}/{}", self.base_prefix, key), - ..Default::default() - }; - - let result = client.head_object(request).await?; - - Ok(HeadObjectResponse { - content_length: result.content_length.unwrap_or(0) as u64, - content_type: result.content_type.unwrap_or_else(|| "".to_string()), - etag: result.e_tag.unwrap_or_else(|| "".to_string()), - last_modified: result - .last_modified - .unwrap_or_else(|| Utc::now().to_rfc2822()), - }) - } - - async fn list_objects_v2( - &self, - prefix: String, - continuation_token: Option, - delimiter: Option, - max_keys: NonZeroU32, - ) -> Result { - let client = self.create_client()?; - - let mut request = ListObjectsV2Request { - bucket: self.bucket.clone(), - prefix: Some(format!("{}/{}", self.base_prefix, prefix)), - delimiter, - max_keys: Some(max_keys.get() as i64), - ..Default::default() - }; - - if let Some(token) = continuation_token { - request.continuation_token = Some(token); - } - - let output = client.list_objects_v2(request).await?; - let result = ListBucketResult { - name: self.account_id.to_string(), - prefix: format!("{}/{}", self.repository_id, prefix), - key_count: output.key_count.unwrap_or(0), - max_keys: output.max_keys.unwrap_or(0), - is_truncated: output.is_truncated.unwrap_or(false), - next_continuation_token: output.next_continuation_token, - contents: output - .contents - .unwrap_or_default() - .iter() - .map(|item| Content { - key: replace_first( - item.key.clone().unwrap_or_default(), - self.base_prefix.clone(), - self.repository_id.to_string(), - ), - last_modified: item - .last_modified - .clone() - .unwrap_or_else(|| Utc::now().to_rfc2822()), - etag: item.e_tag.clone().unwrap_or_default(), - size: item.size.unwrap_or(0), - storage_class: item.storage_class.clone().unwrap_or_default(), - }) - .collect(), - common_prefixes: output - .common_prefixes - .unwrap_or_default() - .iter() - .map(|item| CommonPrefix { - prefix: replace_first( - item.prefix.clone().unwrap_or_default(), - self.base_prefix.clone(), - self.repository_id.to_string(), - ), - }) - .collect(), - }; - - Ok(result) - } - - async fn copy_object( - &self, - copy_identifier_path: String, - key: String, - range: Option, - ) -> Result<(), BackendError> { - let client = self.create_client()?; - - let request = HeadObjectRequest { - bucket: self.bucket.clone(), - key: copy_identifier_path.to_string(), - ..Default::default() - }; - - let result = client.head_object(request).await?; - let url_client = reqwest::Client::new(); - - let url = if self.auth_method == "s3_local" { - format!( - "http://localhost:5050/{}/{}", - self.bucket, copy_identifier_path - ) - } else { - format!( - "https://s3.{}.amazonaws.com/{}/{}", - self.region.name(), - self.bucket, - copy_identifier_path - ) - }; - - let mut request = url_client.get(url); - - if let Some(range_value) = range { - request = request.header(RANGE, range_value); - } - - let response = request.send().await?; - let content_bytes = response - .bytes() - .await - .unwrap_or_else(|_| bytes::Bytes::from(vec![])); - self.put_object(key.clone(), content_bytes, result.content_type) - .await?; - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 6abce68..0000000 --- a/src/main.rs +++ /dev/null @@ -1,509 +0,0 @@ -mod apis; -mod backends; -mod utils; -use crate::utils::core::{split_at_first_slash, StreamingResponse}; -use actix_cors::Cors; -use actix_web::body::{BodySize, BoxBody, MessageBody}; -use actix_web::error::ErrorInternalServerError; -use actix_web::{ - delete, get, head, http::header::CONTENT_TYPE, http::header::RANGE, middleware, post, put, web, - App, HttpRequest, HttpResponse, HttpServer, Responder, -}; - -use apis::source::{RepositoryPermission, SourceApi}; -use apis::Api; -use backends::common::{CommonPrefix, CompleteMultipartUpload, ListBucketResult}; -use bytes::Bytes; -use core::num::NonZeroU32; -use env_logger::Env; -use futures_util::StreamExt; -use quick_xml::se::to_string_with_root; -use serde::Deserialize; -use serde_xml_rs::from_str; -use std::env; -use std::fmt::Debug; -use std::pin::Pin; -use std::str::from_utf8; -use std::task::{Context, Poll}; -use utils::auth::{LoadIdentity, UserIdentity}; -use utils::errors::BackendError; -const VERSION: &str = env!("CARGO_PKG_VERSION"); - -struct FakeBody { - size: usize, -} - -impl MessageBody for FakeBody { - type Error = actix_web::Error; - - fn size(&self) -> BodySize { - BodySize::Sized(self.size as u64) - } - - fn poll_next( - self: Pin<&mut Self>, - _: &mut Context<'_>, - ) -> Poll>> { - Poll::Ready(None) - } -} - -#[get("/{account_id}/{repository_id}/{key:.*}")] -async fn get_object( - api_client: web::Data, - req: HttpRequest, - path: web::Path<(String, String, String)>, - user_identity: web::ReqData, -) -> Result { - let (account_id, repository_id, key) = path.into_inner(); - let headers = req.headers(); - let mut range_start = 0; - let mut is_range_request = false; - - let range = headers - .get(RANGE) - .and_then(|h| h.to_str().ok()) - .and_then(|r| r.strip_prefix("bytes=")) - .and_then(|bytes_range| bytes_range.split_once('-')) - .and_then(|(start, end)| { - start.parse::().ok().map(|s| { - range_start = s; - if end.is_empty() || end.parse::().is_ok() { - is_range_request = true; - Some(format!("bytes={start}-{end}")) - } else { - None - } - }) - }) - .flatten(); - - let client = api_client - .get_backend_client(&account_id, &repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - &repository_id, - RepositoryPermission::Read, - ) - .await?; - - // Found the repository, now try to get the object - let res = client.get_object(key.clone(), range).await?; - - let mut content_length = String::from("*"); - // Remove this if statement to increase performance since it's making an extra request just to get the total content-length - // This is only needed for range requests and in theory, you can return a * in the Content-Range header to indicate that the content length is unknown - if is_range_request { - content_length = client - .head_object(key.clone()) - .await? - .content_length - .to_string(); - } - - let stream = res - .body - .map(|result| result.map_err(|e| ErrorInternalServerError(e.to_string()))); - - let streaming_response = StreamingResponse::new(stream, res.content_length); - let mut response = if is_range_request { - HttpResponse::PartialContent() - } else { - HttpResponse::Ok() - }; - - let mut response = response - .insert_header(("Accept-Ranges", "bytes")) - .insert_header(("Access-Control-Expose-Headers", "Accept-Ranges")) - .insert_header(("Content-Type", res.content_type)) - .insert_header(("Last-Modified", res.last_modified)) - .insert_header(("Content-Length", res.content_length.to_string())) - .insert_header(("ETag", res.etag)); - - if is_range_request { - response = response - .insert_header(( - "Content-Range", - format!( - "bytes {}-{}/{}", - range_start, - range_start + res.content_length - 1, - content_length - ), - )) - .insert_header(( - "Access-Control-Expose-Headers", - "Accept-Ranges, Content-Range", - )); - } - - Ok(response.body(streaming_response)) -} - -#[derive(Debug, Deserialize)] -struct DeleteParams { - #[serde(rename = "uploadId")] - upload_id: Option, -} - -#[delete("/{account_id}/{repository_id}/{key:.*}")] -async fn delete_object( - api_client: web::Data, - params: web::Query, - path: web::Path<(String, String, String)>, - user_identity: web::ReqData, -) -> Result { - let (account_id, repository_id, key) = path.into_inner(); - - let client = api_client - .get_backend_client(&account_id, &repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - &repository_id, - RepositoryPermission::Write, - ) - .await?; - - if params.upload_id.is_none() { - // Found the repository, now try to delete the object - client.delete_object(key.clone()).await?; - Ok(HttpResponse::NoContent().finish()) - } else { - client - .abort_multipart_upload(key.clone(), params.upload_id.clone().unwrap()) - .await?; - Ok(HttpResponse::NoContent().finish()) - } -} - -#[derive(Debug, Deserialize)] -struct PutParams { - #[serde(rename = "partNumber")] - part_number: Option, - #[serde(rename = "uploadId")] - upload_id: Option, -} - -#[put("/{account_id}/{repository_id}/{key:.*}")] -async fn put_object( - api_client: web::Data, - req: HttpRequest, - bytes: Bytes, - params: web::Query, - path: web::Path<(String, String, String)>, - user_identity: web::ReqData, -) -> Result { - let (account_id, repository_id, key) = path.into_inner(); - let headers = req.headers(); - - let client = api_client - .get_backend_client(&account_id, &repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - &repository_id, - RepositoryPermission::Write, - ) - .await?; - - if params.part_number.is_none() && params.upload_id.is_none() { - // Check if this is a server-side copy operation - if let Some(header_copy_identifier) = req.headers().get("x-amz-copy-source") { - let copy_identifier_path = header_copy_identifier.to_str().unwrap_or(""); - client - .copy_object((©_identifier_path).to_string(), key.clone(), None) - .await?; - Ok(HttpResponse::NoContent().finish()) - } else { - // Found the repository, now try to upload the object - client - .put_object( - key.clone(), - bytes, - headers - .get(CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()), - ) - .await?; - Ok(HttpResponse::NoContent().finish()) - } - } else if params.part_number.is_some() && params.upload_id.is_some() { - let res = client - .upload_multipart_part( - key.clone(), - params.upload_id.clone().unwrap(), - params.part_number.clone().unwrap(), - bytes, - ) - .await?; - Ok(HttpResponse::Ok() - .insert_header(("ETag", res.etag)) - .finish()) - } else { - Err(BackendError::InvalidRequest( - "Must provide both part number and upload id or neither.".to_string(), - )) - } -} - -#[derive(Debug, Deserialize)] -struct PostParams { - uploads: Option, - #[serde(rename = "uploadId")] - upload_id: Option, -} - -#[post("/{account_id}/{repository_id}/{key:.*}")] -async fn post_handler( - api_client: web::Data, - req: HttpRequest, - params: web::Query, - mut payload: web::Payload, - path: web::Path<(String, String, String)>, - user_identity: web::ReqData, -) -> Result { - let (account_id, repository_id, key) = path.into_inner(); - let headers = req.headers(); - - let client = api_client - .get_backend_client(&account_id, &repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - &repository_id, - RepositoryPermission::Write, - ) - .await?; - - if params.uploads.is_some() { - let res = client - .create_multipart_upload( - key, - headers - .get(CONTENT_TYPE) - .and_then(|h| h.to_str().ok()) - .map(|s| s.to_string()), - ) - .await?; - let serialized = to_string_with_root("InitiateMultipartUploadResult", &res)?; - Ok(HttpResponse::Ok() - .content_type("application/xml") - .body(serialized)) - } else if params.upload_id.is_some() { - let mut body = String::new(); - while let Some(chunk) = payload.next().await { - match chunk { - Ok(chunk) => match from_utf8(&chunk) { - Ok(s) => body.push_str(s), - Err(_) => { - return Err(BackendError::InvalidRequest("Invalid UTF-8".to_string())) - } - }, - Err(err) => return Err(BackendError::UnexpectedApiError(err.to_string())), - } - } - - let upload = from_str::(&body)?; - let res = client - .complete_multipart_upload(key, params.upload_id.clone().unwrap(), upload.parts) - .await?; - let serialized = to_string_with_root("CompleteMultipartUploadResult", &res)?; - Ok(HttpResponse::Ok() - .content_type("application/xml") - .body(serialized)) - } else { - Err(BackendError::InvalidRequest( - "Must provide either uploads or uploadId".to_string(), - )) - } -} - -#[head("/{account_id}/{repository_id}/{key:.*}")] -async fn head_object( - api_client: web::Data, - path: web::Path<(String, String, String)>, - user_identity: web::ReqData, -) -> Result { - let (account_id, repository_id, key) = path.into_inner(); - - let client = api_client - .get_backend_client(&account_id, &repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - &repository_id, - RepositoryPermission::Read, - ) - .await?; - - let res = client.head_object(key.clone()).await?; - Ok(HttpResponse::Ok() - .insert_header(("Accept-Ranges", "bytes")) - .insert_header(("Access-Control-Expose-Headers", "Accept-Ranges")) - .insert_header(("Content-Type", res.content_type)) - .insert_header(("Last-Modified", res.last_modified)) - .insert_header(("ETag", res.etag)) - .body(BoxBody::new(FakeBody { - size: res.content_length as usize, - }))) -} - -#[derive(Deserialize)] -struct ListObjectsV2Query { - #[serde(rename = "prefix")] - prefix: Option, - #[serde(rename = "list-type")] - _list_type: u8, - #[serde(rename = "max-keys")] - max_keys: Option, - #[serde(rename = "delimiter")] - delimiter: Option, - #[serde(rename = "continuation-token")] - continuation_token: Option, -} - -#[get("/{account_id}")] -async fn list_objects( - api_client: web::Data, - info: web::Query, - path: web::Path, - user_identity: web::ReqData, -) -> Result { - let account_id = path.into_inner(); - - if info.prefix.clone().is_some_and(|s| s.is_empty()) || info.prefix.is_none() { - let account = api_client - .get_account(account_id.clone(), (*user_identity).clone()) - .await?; - - let repositories = account.repositories; - let mut common_prefixes = Vec::new(); - for repository_id in repositories.iter() { - common_prefixes.push(CommonPrefix { - prefix: format!("{}/", repository_id.clone()), - }); - } - let list_response = ListBucketResult { - name: account_id.clone(), - prefix: "/".to_string(), - key_count: 0, - max_keys: 0, - is_truncated: false, - contents: vec![], - common_prefixes, - next_continuation_token: None, - }; - - let serialized = to_string_with_root("ListBucketResult", &list_response)?; - return Ok(HttpResponse::Ok() - .content_type("application/xml") - .body(serialized)); - } - - let path_prefix = info.prefix.clone().unwrap_or("".to_string()); - - let (repository_id, prefix) = split_at_first_slash(&path_prefix); - - let mut max_keys = NonZeroU32::new(1000).unwrap(); - if let Some(mk) = info.max_keys { - max_keys = mk; - } - - let client = api_client - .get_backend_client(&account_id, repository_id) - .await?; - - api_client - .assert_authorized( - user_identity.into_inner(), - &account_id, - repository_id, - RepositoryPermission::Read, - ) - .await?; - - // We're listing within a repository, so we need to query the object store backend - let res = client - .list_objects_v2( - prefix.to_string(), - info.continuation_token.clone(), - info.delimiter.clone(), - max_keys, - ) - .await?; - - let serialized = to_string_with_root("ListBucketResult", &res)?; - - Ok(HttpResponse::Ok() - .content_type("application/xml") - .body(serialized)) -} - -#[get("/")] -async fn index() -> impl Responder { - HttpResponse::Ok().body(format!("Source Cooperative Data Proxy v{VERSION}")) -} - -// Main function to set up and run the HTTP server -#[actix_web::main] -async fn main() -> std::io::Result<()> { - let source_api_url = env::var("SOURCE_API_URL").expect("SOURCE_API_URL must be set"); - let source_api_key = env::var("SOURCE_API_KEY").expect("SOURCE_API_KEY must be set"); - let source_api_proxy_url = env::var("SOURCE_API_PROXY_URL").ok(); // Optional proxy for the Source API - let source_api = web::Data::new(SourceApi::new( - source_api_url, - source_api_key, - source_api_proxy_url, - )); - env_logger::init_from_env(Env::default().default_filter_or("info")); - - HttpServer::new(move || { - App::new() - .app_data(web::PayloadConfig::new(1024 * 1024 * 50)) - .app_data(source_api.clone()) - .app_data(web::Data::new(UserIdentity { api_key: None })) - .wrap( - // Configure CORS - Cors::default() - .allow_any_origin() - .allow_any_method() - .allow_any_header() - .supports_credentials() - .block_on_origin_mismatch(false) - .max_age(3600), - ) - .wrap(middleware::NormalizePath::trim()) - .wrap(middleware::DefaultHeaders::new().add(("X-Version", VERSION))) - .wrap(middleware::Logger::default()) - .wrap(LoadIdentity) - // Register the endpoints - .service(get_object) - .service(delete_object) - .service(post_handler) - .service(put_object) - .service(head_object) - .service(list_objects) - .service(index) - }) - .bind("0.0.0.0:8080")? - .run() - .await -} diff --git a/src/utils/api.rs b/src/utils/api.rs deleted file mode 100644 index 708721b..0000000 --- a/src/utils/api.rs +++ /dev/null @@ -1,45 +0,0 @@ -use crate::utils::errors::BackendError; -use reqwest::{Response, StatusCode}; -use serde::de::DeserializeOwned; - -/// Process a response, handling both success and error cases -pub async fn process_json_response( - response: Response, - not_found_error: BackendError, -) -> Result { - let status = response.status(); - let url = response.url().to_string(); - let text = response - .text() - .await - .unwrap_or_else(|_| "".to_string()); - - if status.is_success() { - match serde_json::from_str::(&text) { - Ok(val) => Ok(val), - Err(err) => { - log::error!("Failed to parse JSON from {}: {}\nBody: {}", url, err, text); - Err(BackendError::JsonParseError { url }) - } - } - } else if status == StatusCode::NOT_FOUND { - Err(not_found_error) - } else { - let is_server_error = status.is_server_error(); - if is_server_error { - log::error!("Server error from {}: {}\nBody: {}", url, status, text); - Err(BackendError::ApiServerError { - url, - status: status.as_u16(), - message: text, - }) - } else { - log::warn!("Client error from {}: {}\nBody: {}", url, status, text); - Err(BackendError::ApiClientError { - url, - status: status.as_u16(), - message: text, - }) - } - } -} diff --git a/src/utils/auth.rs b/src/utils/auth.rs deleted file mode 100644 index dd6b8c2..0000000 --- a/src/utils/auth.rs +++ /dev/null @@ -1,505 +0,0 @@ -use actix_http::header::HeaderMap; -use actix_web::{ - dev::{self, Service, ServiceRequest, ServiceResponse, Transform}, - web, - web::BytesMut, - Error, HttpMessage, -}; -use futures_util::{future::LocalBoxFuture, stream::StreamExt}; -use hex; -use hmac::{Hmac, Mac}; -use percent_encoding::percent_decode_str; -use sha2::{Digest, Sha256}; -use std::{ - borrow::Cow, - collections::BTreeMap, - future::{ready, Ready}, - rc::Rc, -}; -use url::form_urlencoded; - -use crate::apis::source::{APIKey, SourceApi}; -use crate::utils::errors::BackendError; -use async_trait::async_trait; - -#[async_trait] -pub trait ApiKeyProvider: Send + Sync { - async fn get_api_key(&self, access_key_id: &str) -> Result; -} - -#[async_trait] -impl ApiKeyProvider for SourceApi { - async fn get_api_key(&self, access_key_id: &str) -> Result { - self.get_api_key(access_key_id).await - } -} - -#[derive(Clone)] -pub struct UserIdentity { - pub api_key: Option, -} - -pub struct LoadIdentity; - -impl Transform for LoadIdentity -where - S: Service, Error = Error>, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse; - type Error = Error; - type InitError = (); - type Transform = LoadIdentityMiddleware; - type Future = Ready>; - - fn new_transform(&self, service: S) -> Self::Future { - ready(Ok(LoadIdentityMiddleware { - service: Rc::new(service), - })) - } -} - -pub struct LoadIdentityMiddleware { - service: Rc, -} - -impl Service for LoadIdentityMiddleware -where - S: Service, Error = Error> + 'static, - S::Future: 'static, - B: 'static, -{ - type Response = ServiceResponse; - type Error = Error; - type Future = LocalBoxFuture<'static, Result>; - - dev::forward_ready!(service); - - fn call(&self, mut req: ServiceRequest) -> Self::Future { - let svc = self.service.clone(); - - Box::pin(async move { - let mut body = BytesMut::new(); - let mut stream = req.take_payload(); - while let Some(chunk) = stream.next().await { - body.extend_from_slice(&chunk?); - } - - let identity = match load_identity( - req.app_data::>().unwrap(), - req.method().as_str(), - req.path(), - req.headers(), - req.query_string(), - &body, - ) - .await - { - Ok(api_key) => UserIdentity { - api_key: Some(api_key), - }, - Err(_) => UserIdentity { api_key: None }, - }; - - req.extensions_mut().insert(identity); - - let (_, mut payload) = actix_http::h1::Payload::create(true); - - payload.unread_data(body.into()); - req.set_payload(payload.into()); - - let res = svc.call(req).await?; - - Ok(res) - }) - } -} - -async fn load_identity( - source_api: &web::Data, - method: &str, - path: &str, - headers: &HeaderMap, - query_string: &str, - body: &BytesMut, -) -> Result -where - T: ApiKeyProvider, -{ - let Some(auth) = headers.get("Authorization") else { - return Err("No Authorization header found".to_string()); - }; - - let authorization_header = auth.to_str().unwrap(); - let signature_method = authorization_header.split(" ").next().unwrap(); - - if signature_method != "AWS4-HMAC-SHA256" { - return Err("Invalid Signature Algorithm".to_string()); - } - - let parts = authorization_header - .split(",") - .map(|part| part.trim()) - .collect::>(); - - let credential = parts[0].split("Credential=").nth(1).unwrap_or(""); - let signed_headers = parts[1] - .split("SignedHeaders=") - .nth(1) - .unwrap_or("") - .split(";") - .collect(); - let signature = parts[2].split("Signature=").nth(1).unwrap_or(""); - - let parts = credential.split("/").collect::>(); - let access_key_id = parts[0]; - let date = parts[1]; - let region = parts[2]; - let service = parts[3]; - - let Some(content_hash) = headers.get("x-amz-content-sha256") else { - return Err("No x-amz-content-sha256 header found".to_string()); - }; - - let canonical_request = create_canonical_request( - method, - path, - headers, - signed_headers, - query_string, - body, - content_hash.to_str().unwrap(), - ); - let credential_scope = format!("{date}/{region}/{service}/aws4_request"); - - let Some(datetime) = headers.get("x-amz-date") else { - return Err("No x-amz-date header found".to_string()); - }; - - let api_key = source_api - .get_api_key(access_key_id) - .await - .map_err(|e| e.to_string())?; - - let string_to_sign = create_string_to_sign( - &canonical_request, - datetime.to_str().unwrap(), - &credential_scope, - ); - - let calculated_signature = calculate_signature( - api_key.secret_access_key.as_str(), - date, - region, - service, - &string_to_sign, - ); - - if calculated_signature != signature { - Err("Signature mismatch".to_string()) - } else { - Ok(api_key) - } -} - -fn uri_encode(input: &str, encode_forward_slash: bool) -> Cow<'_, str> { - let mut encoded = String::new(); - let chars = input.chars().peekable(); - - for ch in chars { - if (ch == '/' && !encode_forward_slash) - || (ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' || ch == '~') - { - encoded.push(ch); - } else { - for byte in ch.to_string().as_bytes() { - encoded.push_str(&format!("%{byte:02X}")); - } - } - } - - if encoded == input { - Cow::Borrowed(input) - } else { - Cow::Owned(encoded) - } -} - -fn trim(input: &str) -> String { - input.trim().to_string() -} - -fn lowercase(input: &str) -> String { - input.to_lowercase() -} - -fn hmac_sha256(key: &[u8], message: &[u8]) -> Vec { - // Create HMAC-SHA256 instance - let mut mac = Hmac::::new_from_slice(key).expect("HMAC can take key of any size"); - - // Add message to HMAC - mac.update(message); - - // Calculate HMAC - let result = mac.finalize(); - - // Get the result as bytes - result.into_bytes().to_vec() -} - -fn calculate_signature( - key: &str, - date: &str, - region: &str, - service: &str, - string_to_sign: &str, -) -> String { - let k_date = hmac_sha256(format!("AWS4{key}").as_bytes(), date.as_bytes()); - let k_region = hmac_sha256(&k_date, region.as_bytes()); - let k_service = hmac_sha256(&k_region, service.as_bytes()); - let k_signing = hmac_sha256(&k_service, b"aws4_request"); - - hex::encode(hmac_sha256(&k_signing, string_to_sign.as_bytes())) -} - -fn create_string_to_sign( - canonical_request: &str, - datetime: &str, - credential_scope: &str, -) -> String { - format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - datetime, - credential_scope, - hex::encode(Sha256::digest(canonical_request.as_bytes())) - ) -} - -fn create_canonical_request( - method: &str, - path: &str, - headers: &HeaderMap, - signed_headers: Vec<&str>, - query_string: &str, - body: &BytesMut, - content_hash: &str, -) -> String { - let decoded_path = percent_decode_str(path).decode_utf8().unwrap(); - if content_hash == "UNSIGNED-PAYLOAD" { - return format!( - "{}\n{}\n{}\n{}\n{}\n{}", - method, - uri_encode(decoded_path.as_ref(), false), - get_canonical_query_string(query_string), - get_canonical_headers(headers, &signed_headers), - get_signed_headers(&signed_headers), - content_hash - ); - } - format!( - "{}\n{}\n{}\n{}\n{}\n{}", - method, - uri_encode(decoded_path.as_ref(), false), - get_canonical_query_string(query_string), - get_canonical_headers(headers, &signed_headers), - get_signed_headers(&signed_headers), - hash_payload(body) - ) -} - -fn get_canonical_query_string(query_string: &str) -> String { - if query_string.is_empty() { - return String::new(); - } - - let parsed: Vec<(String, String)> = form_urlencoded::parse(query_string.as_bytes()) - .map(|(key, value)| (key.to_string(), value.to_string())) - .collect(); - - let mut sorted_params: Vec<(String, String)> = parsed; - sorted_params.sort_by(|a, b| a.0.cmp(&b.0)); - - let mut encoded_params: Vec = Vec::new(); - - for (key, value) in sorted_params { - let encoded_key = uri_encode(&key, true); - let encoded_value = uri_encode(&value, true); - - encoded_params.push(format!("{encoded_key}={encoded_value}")); - } - - encoded_params.join("&") -} - -fn get_canonical_headers(headers: &HeaderMap, signed_headers: &Vec<&str>) -> String { - let mut canonical_headers = BTreeMap::new(); - - for (name, value) in headers.iter() { - let canonical_name = lowercase(name.as_str()); - let canonical_value = trim(value.to_str().unwrap()); - - if signed_headers.contains(&canonical_name.as_str()) { - canonical_headers - .entry(canonical_name) - .or_insert_with(Vec::new) - .push(canonical_value); - } - } - - canonical_headers - .iter() - .fold(String::new(), |mut output, (name, values)| { - output.push_str(&format!("{}:{}\n", name, values.join(","))); - output - }) -} - -fn get_signed_headers(signed_headers: &Vec<&str>) -> String { - signed_headers - .iter() - .map(|header| lowercase(header)) - .collect::>() - .join(";") -} - -fn hash_payload(body: &BytesMut) -> String { - hex::encode(Sha256::digest(body)) -} - -#[cfg(test)] -mod tests { - use super::*; - use actix_http::header::{HeaderMap, HeaderName, HeaderValue}; - use async_trait::async_trait; - use common_s3_headers::S3HeadersBuilder; - use std::str::FromStr; - use url::Url; - - #[derive(Clone)] - struct TestSourceApi { - api_key: Option, - } - - impl TestSourceApi { - fn new(api_key: Option) -> Self { - Self { api_key } - } - } - - #[async_trait] - impl ApiKeyProvider for TestSourceApi { - async fn get_api_key(&self, _access_key_id: &str) -> Result { - let Some(key) = &self.api_key else { - return Err(BackendError::ApiKeyNotFound); - }; - Ok(key.clone()) - } - } - - fn create_test_source_api(api_key: Option) -> web::Data { - web::Data::new(TestSourceApi::new(api_key)) - } - - #[tokio::test] - async fn test_load_identity_missing_auth_header() { - let headers = HeaderMap::new(); - let source_api = create_test_source_api(None); - - let result = - load_identity(&source_api, "GET", "/test", &headers, "", &BytesMut::new()).await; - - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "No Authorization header found"); - } - - #[tokio::test] - async fn test_load_identity_invalid_signature_method() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_str("Authorization").unwrap(), - HeaderValue::from_str("INVALID Credential=test-key/20240315/us-east-1/s3, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=test-signature").unwrap(), - ); - - let source_api = create_test_source_api(None); - - let result = - load_identity(&source_api, "GET", "/test", &headers, "", &BytesMut::new()).await; - - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "Invalid Signature Algorithm"); - } - - #[tokio::test] - async fn test_load_identity_missing_content_hash() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_str("Authorization").unwrap(), - HeaderValue::from_str("AWS4-HMAC-SHA256 Credential=test-key/20240315/us-east-1/s3, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=test-signature").unwrap(), - ); - - let source_api = create_test_source_api(None); - - let result = - load_identity(&source_api, "GET", "/test", &headers, "", &BytesMut::new()).await; - - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "No x-amz-content-sha256 header found"); - } - - #[tokio::test] - async fn test_load_identity_missing_date() { - let mut headers = HeaderMap::new(); - headers.insert( - HeaderName::from_str("Authorization").unwrap(), - HeaderValue::from_str("AWS4-HMAC-SHA256 Credential=test-key/20240315/us-east-1/s3, SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature=test-signature").unwrap(), - ); - headers.insert( - HeaderName::from_str("x-amz-content-sha256").unwrap(), - HeaderValue::from_str("test-hash").unwrap(), - ); - - let source_api = create_test_source_api(None); - - let result = - load_identity(&source_api, "GET", "/test", &headers, "", &BytesMut::new()).await; - - assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "No x-amz-date header found"); - } - - #[tokio::test] - async fn test_load_identity_success() { - let api_key = APIKey { - access_key_id: "test-key".to_string(), - secret_access_key: "test-secret".to_string(), - }; - let source_api = create_test_source_api(Some(api_key.clone())); - - let method = "GET"; - let url = Url::parse("https://test.com/test").unwrap(); - let path = url.path(); - - let headers = HeaderMap::from_iter( - S3HeadersBuilder::new(&url) - .set_access_key(api_key.access_key_id.as_str()) - .set_secret_key(api_key.secret_access_key.as_str()) - .set_region("us-east-1") - .set_method("GET") - .set_service("s3") - .build() - .iter() - .map(|(k, v)| { - ( - HeaderName::from_str(k).unwrap(), - HeaderValue::from_str(v.as_str()).unwrap(), - ) - }), - ); - - let result = load_identity(&source_api, method, path, &headers, "", &BytesMut::new()).await; - - assert!(result.is_ok()); - assert_eq!(result.unwrap().access_key_id, "test-key"); - } -} diff --git a/src/utils/core.rs b/src/utils/core.rs deleted file mode 100644 index 6d2d3f2..0000000 --- a/src/utils/core.rs +++ /dev/null @@ -1,99 +0,0 @@ -use actix_web::{ - body::{BodySize, MessageBody}, - web, Error as ActixError, -}; -use futures::Stream; -use pin_project_lite::pin_project; -use std::pin::Pin; -use std::task::{Context, Poll}; - -pin_project! { - pub struct StreamingResponse { - #[pin] - inner: S, - size: u64, - } -} - -impl StreamingResponse { - pub fn new(inner: S, size: u64) -> Self { - Self { inner, size } - } -} - -impl MessageBody for StreamingResponse -where - S: Stream, - S::Item: Into>, -{ - type Error = ActixError; - - fn size(&self) -> BodySize { - BodySize::Sized(self.size) - } - - fn poll_next( - self: Pin<&mut Self>, - cx: &mut Context<'_>, - ) -> Poll>> { - let this = self.project(); - match this.inner.poll_next(cx) { - Poll::Ready(Some(item)) => Poll::Ready(Some(item.into())), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -pub fn replace_first(original: String, from: String, to: String) -> String { - match original.find(&from) { - Some(start_index) => { - let mut result = String::with_capacity(original.len()); - result.push_str(&original[..start_index]); - result.push_str(&to); - result.push_str(&original[start_index + from.len()..]); - result - } - None => original, - } -} - -/// Splits a string at the first forward slash ('/') character. -/// -/// This function takes a string as input and returns a tuple of two strings. -/// The first string in the tuple contains the part of the input before the -/// first slash, and the second string contains the part after the first slash. -/// -/// # Arguments -/// -/// * `input` - A String that may or may not contain a forward slash. -/// -/// # Returns -/// -/// A tuple `(String, String)` where: -/// - The first element is the substring before the first slash. -/// - The second element is the substring after the first slash. -/// -/// If there is no slash in the input string, the function returns the entire -/// input as the first element of the tuple and an empty string as the second element. -/// -/// # Examples -/// -/// ``` -/// let (before, after) = split_at_first_slash("path/to/file".to_string()); -/// assert_eq!(before, "path"); -/// assert_eq!(after, "to/file"); -/// -/// let (before, after) = split_at_first_slash("no_slash".to_string()); -/// assert_eq!(before, "no_slash"); -/// assert_eq!(after, ""); -/// ``` -pub fn split_at_first_slash(input: &str) -> (&str, &str) { - match input.find('/') { - Some(index) => { - let (before, after) = input.split_at(index); - (before, &after[1..]) - } - None => (input, ""), - } -} diff --git a/src/utils/errors.rs b/src/utils/errors.rs deleted file mode 100644 index 3adbc9a..0000000 --- a/src/utils/errors.rs +++ /dev/null @@ -1,654 +0,0 @@ -use actix_web::error; -use actix_web::http::StatusCode; -use actix_web::HttpResponse; -use azure_core::{ - error::{Error as AzureError, ErrorKind as AzureErrorKind}, - StatusCode as AzureStatusCode, -}; -use log::error; -use quick_xml::DeError; -use reqwest::Error as ReqwestError; -use rusoto_core::RusotoError; -use rusoto_s3::{ - AbortMultipartUploadError, CompleteMultipartUploadError, CreateMultipartUploadError, - DeleteObjectError, HeadObjectError, ListObjectsV2Error, PutObjectError, UploadPartError, -}; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum BackendError { - #[error("repository not found")] - RepositoryNotFound, - - #[error("failed to fetch repository permissions")] - RepositoryPermissionsNotFound, - - #[error("source repository missing primary mirror")] - SourceRepositoryMissingPrimaryMirror, - - #[error("object not found: {}", .0.clone().unwrap_or_default())] - ObjectNotFound(Option), - - #[error("api key not found")] - ApiKeyNotFound, - - #[error("data connection not found")] - DataConnectionNotFound, - - #[error("invalid request")] - InvalidRequest(String), - - #[error("reqwest error (url {}, message {})", .0.url().map(|u| u.to_string()).unwrap_or("unknown".to_string()), .0.to_string())] - ReqwestError(#[from] ReqwestError), - - #[error("api threw a server error (url {}, status {}, message {})", .url, .status, .message)] - ApiServerError { - url: String, - status: u16, - message: String, - }, - - #[error("api threw a client error (url {}, status {}, message {})", .url, .status, .message)] - ApiClientError { - url: String, - status: u16, - message: String, - }, - - #[error("failed to parse JSON (url {})", .url)] - JsonParseError { url: String }, - - #[error("unexpected data connection provider (provider {})", .provider)] - UnexpectedDataConnectionProvider { provider: String }, - - #[error("unauthorized")] - UnauthorizedError, - - #[error("unexpected API error: {0}")] - UnexpectedApiError(String), - - #[error("unsupported auth method: {0}")] - UnsupportedAuthMethod(String), - - #[error("unsupported operation: {0}")] - UnsupportedOperation(String), - - #[error("xml parse error: {0}")] - XmlParseError(String), - - #[error("azure error: {0}")] - AzureError(AzureError), - - #[error("s3 error: {0}")] - S3Error(String), -} - -impl error::ResponseError for BackendError { - fn error_response(&self) -> HttpResponse { - let status_code = self.status_code(); - let body = match status_code { - e if e.is_client_error() => self.to_string(), - _ => format!("Internal Server Error: {self}"), - }; - if status_code.is_server_error() { - error!("Error: {}", self); - } - HttpResponse::build(status_code).body(body) - } - - fn status_code(&self) -> StatusCode { - match self { - // Pass through client error status codes - BackendError::ApiClientError { status, .. } => { - StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_REQUEST) - } - - // 400 - BackendError::InvalidRequest(_) - | BackendError::UnsupportedAuthMethod(_) - | BackendError::UnsupportedOperation(_) => StatusCode::BAD_REQUEST, - // 401 - BackendError::UnauthorizedError => StatusCode::UNAUTHORIZED, - // 404 - BackendError::RepositoryNotFound - | BackendError::ObjectNotFound(_) - | BackendError::SourceRepositoryMissingPrimaryMirror - | BackendError::ApiKeyNotFound - | BackendError::DataConnectionNotFound => StatusCode::NOT_FOUND, - - // 502 - BackendError::ReqwestError(_) - | BackendError::ApiServerError { .. } - | BackendError::RepositoryPermissionsNotFound - | BackendError::AzureError(_) - | BackendError::S3Error(_) => StatusCode::BAD_GATEWAY, - // 500 - _ => StatusCode::INTERNAL_SERVER_ERROR, - } - } -} - -// Azure API Errors -impl From for BackendError { - fn from(error: AzureError) -> BackendError { - match error.kind() { - AzureErrorKind::HttpResponse { status, error_code } - if *status == AzureStatusCode::NotFound => - { - BackendError::ObjectNotFound(error_code.clone()) - } - _ => BackendError::AzureError(error), - } - } -} - -// S3 API Errors -fn get_rusoto_error_message( - operation: &str, - error: RusotoError, -) -> String { - match error { - RusotoError::Service(e) => format!("{operation} Service Error: {e}"), - RusotoError::HttpDispatch(e) => format!("{operation} HttpDispatch Error: {e}"), - RusotoError::Credentials(e) => format!("{operation} Credentials Error: {e}"), - RusotoError::Validation(e) => format!("{operation} Validation Error: {e}"), - RusotoError::ParseError(e) => format!("{operation} Parse Error: {e}"), - RusotoError::Unknown(e) => format!("{} Unknown Error: status {}", operation, e.status), - RusotoError::Blocking => format!("{operation} Blocking Error"), - } -} -macro_rules! impl_s3_errors { - ($(($error_type:ty, $operation:expr)),* $(,)?) => { - $( - impl From> for BackendError { - fn from(error: RusotoError<$error_type>) -> BackendError { - BackendError::S3Error(get_rusoto_error_message($operation, error)) - } - } - )* - }; -} -impl_s3_errors!( - (DeleteObjectError, "DeleteObject"), - (PutObjectError, "PutObject"), - (CreateMultipartUploadError, "CreateMultipartUpload"), - (AbortMultipartUploadError, "AbortMultipartUpload"), - (CompleteMultipartUploadError, "CompleteMultipartUpload"), - (UploadPartError, "UploadPart"), -); -impl From> for BackendError { - fn from(error: RusotoError) -> BackendError { - match error { - RusotoError::Service(HeadObjectError::NoSuchKey(e)) => { - BackendError::ObjectNotFound(Some(e)) - } - RusotoError::Unknown(e) if e.status == StatusCode::NOT_FOUND => { - BackendError::ObjectNotFound(Some(e.body_as_str().to_string())) - } - _ => BackendError::S3Error(get_rusoto_error_message("HeadObject", error)), - } - } -} -impl From> for BackendError { - fn from(error: RusotoError) -> BackendError { - match error { - RusotoError::Service(ListObjectsV2Error::NoSuchBucket(_)) => { - BackendError::RepositoryNotFound - } - _ => BackendError::S3Error(get_rusoto_error_message("ListObjectsV2", error)), - } - } -} - -impl From for BackendError { - fn from(error: DeError) -> BackendError { - BackendError::XmlParseError(format!("failed to parse xml: {error}")) - } -} -impl From for BackendError { - fn from(error: serde_xml_rs::Error) -> BackendError { - BackendError::XmlParseError(format!("failed to parse xml: {error}")) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use actix_web::body::to_bytes; - use actix_web::error::ResponseError; - use actix_web::http::StatusCode; - use bytes::Bytes; - use quick_xml::DeError; - use rusoto_core::RusotoError; - use rusoto_s3::{HeadObjectError, ListObjectsV2Error, PutObjectError}; - use serde_xml_rs::Error as XmlError; - - /// Tests for S3 error handling - mod s3_errors { - use super::*; - - #[tokio::test] - async fn should_convert_head_object_no_such_key_to_404() { - let error = RusotoError::Service(HeadObjectError::NoSuchKey("test-key".to_string())); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::ObjectNotFound(_)), - "expected error to be ObjectNotFound" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - let response = backend_error.error_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("object not found: test-key") - ); - } - - #[tokio::test] - async fn should_convert_list_objects_no_such_bucket_to_404() { - let error = - RusotoError::Service(ListObjectsV2Error::NoSuchBucket("test-bucket".to_string())); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::RepositoryNotFound), - "expected error to be converted to RepositoryNotFound" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - let response = backend_error.error_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("repository not found") - ); - } - - #[tokio::test] - async fn should_convert_put_object_unknown_error_to_502() { - let error: RusotoError = - RusotoError::Unknown(rusoto_core::request::BufferedHttpResponse { - status: StatusCode::INTERNAL_SERVER_ERROR, - headers: Default::default(), - body: Bytes::new(), - }); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::S3Error(_)), - "expected error to be converted to S3Error" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::BAD_GATEWAY, - "expected status code to be 502" - ); - let response = backend_error.error_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("Internal Server Error: s3 error: PutObject Unknown Error: status 500 Internal Server Error") - ); - } - } - - /// Tests for Azure error handling - mod azure_errors { - use super::*; - - #[tokio::test] - async fn should_convert_not_found_to_404() { - let error = AzureError::new( - AzureErrorKind::HttpResponse { - status: AzureStatusCode::NotFound, - error_code: Some("ResourceNotFound".to_string()), - }, - "Resource not found", - ); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::ObjectNotFound(_)), - "expected error to be converted to ObjectNotFound" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - let response = backend_error.error_response(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("object not found: ResourceNotFound") - ); - } - - #[tokio::test] - async fn should_convert_other_errors_to_502() { - let error = AzureError::new( - AzureErrorKind::HttpResponse { - status: AzureStatusCode::InternalServerError, - error_code: Some("InternalError".to_string()), - }, - "Internal error", - ); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::AzureError(_)), - "expected error to be converted to AzureError" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::BAD_GATEWAY, - "expected status code to be 502" - ); - let response = backend_error.error_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("Internal Server Error: azure error: Internal error") - ); - } - } - - /// Tests for client-side error handling - mod client_errors { - use super::*; - - #[tokio::test] - async fn should_handle_unauthorized_error() { - let error = BackendError::UnauthorizedError; - assert_eq!( - error.status_code(), - StatusCode::UNAUTHORIZED, - "expected status code to be 401" - ); - assert_eq!( - error.to_string(), - "unauthorized", - "expected error message to be 'unauthorized'" - ); - let response = error.error_response(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("unauthorized") - ); - } - - #[tokio::test] - async fn should_handle_invalid_request_error() { - let error = BackendError::InvalidRequest("bad input".to_string()); - assert_eq!( - error.status_code(), - StatusCode::BAD_REQUEST, - "expected status code to be 400" - ); - assert_eq!( - error.to_string(), - "invalid request", - "expected error message to be 'invalid request'" - ); - let response = error.error_response(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("invalid request") - ); - } - - #[tokio::test] - async fn should_handle_unsupported_auth_method() { - let error = BackendError::UnsupportedAuthMethod("basic".to_string()); - assert_eq!( - error.status_code(), - StatusCode::BAD_REQUEST, - "expected status code to be 400" - ); - assert_eq!( - error.to_string(), - "unsupported auth method: basic", - "expected error message to include auth method" - ); - let response = error.error_response(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("unsupported auth method: basic") - ); - } - - #[tokio::test] - async fn should_handle_unsupported_operation() { - let error = BackendError::UnsupportedOperation("delete".to_string()); - assert_eq!( - error.status_code(), - StatusCode::BAD_REQUEST, - "expected status code to be 400" - ); - assert_eq!( - error.to_string(), - "unsupported operation: delete", - "expected error message to include operation" - ); - let response = error.error_response(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!( - to_bytes(response.into_body()).await.unwrap(), - Bytes::from("unsupported operation: delete") - ); - } - } - - /// Tests for XML parsing errors - mod xml_errors { - use super::*; - - #[test] - fn should_convert_quick_xml_error() { - let error = DeError::UnexpectedStart(b"unexpected start of stream".to_vec()); - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::XmlParseError(_)), - "expected error to be converted to XmlParseError" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::INTERNAL_SERVER_ERROR, - "expected status code to be 500" - ); - } - - #[test] - fn should_convert_serde_xml_error() { - let error = XmlError::Custom { - field: "invalid XML format".to_string(), - }; - let backend_error = BackendError::from(error); - - assert!( - matches!(backend_error, BackendError::XmlParseError(_)), - "expected error to be converted to XmlParseError" - ); - assert_eq!( - backend_error.status_code(), - StatusCode::INTERNAL_SERVER_ERROR, - "expected status code to be 500" - ); - } - } - - /// Tests for API-related errors - mod api_errors { - use super::*; - - #[test] - fn should_handle_api_server_error() { - let error = BackendError::ApiServerError { - url: "https://api.example.com".to_string(), - status: 500, - message: "Internal Server Error".to_string(), - }; - assert_eq!( - error.status_code(), - StatusCode::BAD_GATEWAY, - "expected status code to be 502" - ); - assert!( - error.to_string().contains("api threw a server error"), - "expected error message to mention server error" - ); - } - - #[test] - fn should_handle_api_client_error_400() { - let error = BackendError::ApiClientError { - url: "https://api.example.com".to_string(), - status: 400, - message: "Bad Request".to_string(), - }; - assert_eq!( - error.status_code(), - StatusCode::BAD_REQUEST, - "expected status code to be 400" - ); - assert!( - error.to_string().contains("api threw a client error"), - "expected error message to mention client error" - ); - } - - #[test] - fn should_handle_api_client_error_404() { - let error = BackendError::ApiClientError { - url: "https://api.example.com".to_string(), - status: 404, - message: "Bad Request".to_string(), - }; - assert_eq!( - error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - assert!( - error.to_string().contains("api threw a client error"), - "expected error message to mention client error" - ); - } - - #[test] - fn should_handle_json_parse_error() { - let error = BackendError::JsonParseError { - url: "https://api.example.com".to_string(), - }; - assert_eq!( - error.status_code(), - StatusCode::INTERNAL_SERVER_ERROR, - "expected status code to be 500" - ); - assert!( - error.to_string().contains("failed to parse JSON"), - "expected error message to mention JSON parsing" - ); - } - } - - /// Tests for repository-related errors - mod repository_errors { - use super::*; - - #[test] - fn should_handle_repository_not_found() { - let error = BackendError::RepositoryNotFound; - assert_eq!( - error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - assert_eq!( - error.to_string(), - "repository not found", - "expected error message to be 'repository not found'" - ); - } - - #[test] - fn should_handle_repository_permissions_not_found() { - let error = BackendError::RepositoryPermissionsNotFound; - assert_eq!( - error.status_code(), - StatusCode::BAD_GATEWAY, - "expected status code to be 502" - ); - assert_eq!( - error.to_string(), - "failed to fetch repository permissions", - "expected error message to mention permissions" - ); - } - - #[test] - fn should_handle_source_repository_missing_primary_mirror() { - let error = BackendError::SourceRepositoryMissingPrimaryMirror; - assert_eq!( - error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - assert_eq!( - error.to_string(), - "source repository missing primary mirror", - "expected error message to mention missing mirror" - ); - } - } - - /// Tests for data connection errors - mod data_connection_errors { - use super::*; - - #[test] - fn should_handle_data_connection_not_found() { - let error = BackendError::DataConnectionNotFound; - assert_eq!( - error.status_code(), - StatusCode::NOT_FOUND, - "expected status code to be 404" - ); - assert_eq!( - error.to_string(), - "data connection not found", - "expected error message to be 'data connection not found'" - ); - } - - #[test] - fn should_handle_unexpected_data_connection_provider() { - let error = BackendError::UnexpectedDataConnectionProvider { - provider: "unknown".to_string(), - }; - assert_eq!( - error.status_code(), - StatusCode::INTERNAL_SERVER_ERROR, - "expected status code to be 500" - ); - assert!( - error - .to_string() - .contains("unexpected data connection provider"), - "expected error message to mention unexpected provider" - ); - } - } -} diff --git a/src/utils/mod.rs b/src/utils/mod.rs deleted file mode 100644 index c80fecf..0000000 --- a/src/utils/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod api; -pub mod auth; -pub mod core; -pub mod errors;