Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ env:
jobs:
checks:
runs-on: ubuntu-latest
env:
HERALD_SWIFTTEST_OS_USERNAME: ${{ secrets.OPENSTACK_USERNAME }}
HERALD_SWIFTTEST_OS_PASSWORD: ${{ secrets.OPENSTACK_PASSWORD }}
HERALD_SWIFTTEST_OS_PROJECT_NAME: ${{ secrets.OPENSTACK_PROJECT }}
steps:
- uses: actions/checkout@v4
with:
Expand Down
79 changes: 74 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,78 @@ backends:
# Glob pattern support within the map
"test-*":
region: us-east-1
minio2:
# simple config for matching glob buckets
buckets: "my-*"

# Example Swift backend
swift-storage:
protocol: swift
auth_url: http://keystone.example.com/v3
region: RegionOne
# Optional: override the Swift container name for all buckets in this backend
# container: my-fixed-container
credentials:
username: my-user
password: my-password
project_name: my-project
user_domain_name: Default
project_domain_name: Default
# Route all archive buckets to Swift
buckets: "archive-*"

cors:
# Global CORS defaults
allowedOrigins: ["*"]
allowedMethods: ["GET", "PUT", "POST", "DELETE", "HEAD", "OPTIONS"]
allowedHeaders: ["*"]
exposedHeaders: ["*"]
maxAge: 3600
credentials: false
```

### CORS Configuration

Herald supports fine-grained CORS control at three levels with the following
precedence: **Bucket > Backend > Global**.

- **Global**: Defined at the root of the config file under `cors`.
- **Backend**: Defined within a backend block under `cors`. Overrides global
settings.
- **Bucket**: Defined within a bucket definition under `cors`. Overrides both
backend and global settings.

#### Default Behavior

If no CORS configuration is provided at any level, **CORS is disabled** and
Herald will not add any CORS-related headers to responses. Preflight `OPTIONS`
requests will be passed through to the backend.

If you enable CORS by providing configuration at any level, the following
defaults are applied for any omitted fields:

| Field | Default Value | Description |
| ---------------- | --------------------------------------- | ------------------------------------------------------ |
| `maxAge` | `3600` | Max age in seconds for preflight results |
| `allowedMethods` | `GET, PUT, POST, DELETE, HEAD, OPTIONS` | Allowed HTTP methods |
| `allowedHeaders` | (Mirrors request) | Defaults to mirroring `Access-Control-Request-Headers` |
| `credentials` | `false` | Whether to allow credentials |
| `allowedOrigins` | (None) | Headers only added if `Origin` matches an entry |

Example with overrides:

```yaml
cors: # Global defaults
allowedOrigins: ["*"]
credentials: false

backends:
prod:
protocol: s3
cors: # Backend-level override
allowedOrigins: ["https://app.example.com"]
credentials: true
buckets:
assets:
cors: # Bucket-level override
allowedOrigins: ["https://cdn.example.com"]
```

### Routing Logic
Expand All @@ -81,5 +150,5 @@ resolves the backend using the following priority:
1. **Direct match**: Looks for `my-bucket` in all backends' `buckets` maps.
2. **Glob match (map)**: Looks for glob patterns (like `test-*`) in all
backends' `buckets` maps.
3. **Glob match (string)**: If a backend has `buckets: "..."`, it checks if the
bucket name matches that pattern.
3. **Glob match (string)**: If a backend has `buckets: "string-*"`, it checks if
the bucket name matches that pattern.
6 changes: 2 additions & 4 deletions benchmarks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,7 @@ export type BenchmarkCase = {

export const getSwiftConfig = () =>
Effect.gen(function* () {
const authUrl = yield* Config.string("HEARLD_SWIFTTEST_AUTH_URL").pipe(
Config.orElse(() => Config.string("HERALD_SWIFTTEST_AUTH_URL")),
const authUrl = yield* Config.string("HERALD_SWIFTTEST_AUTH_URL").pipe(
Config.orElse(() => Config.string("OS_AUTH_URL")),
Config.withDefault("http://localhost:8080/auth/v1.0"),
Config.option,
Expand All @@ -56,8 +55,7 @@ export const getSwiftConfig = () =>
Config.orElse(() => Config.string("OS_PROJECT_NAME")),
Config.option,
);
const region = yield* Config.string("HEARLD_SWIFTTEST_OS_REGION_NAME").pipe(
Config.orElse(() => Config.string("HERALD_SWIFTTEST_OS_REGION_NAME")),
const region = yield* Config.string("HERALD_SWIFTTEST_OS_REGION_NAME").pipe(
Config.orElse(() => Config.string("TF_VAR_OS_REGION_NAME")),
Config.orElse(() => Config.string("OS_REGION_NAME")),
Config.withDefault("dc3-a"),
Expand Down
75 changes: 74 additions & 1 deletion src/Config/Layer.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Config, Context, Effect, Layer, type Option, Schema } from "effect";
import { parse } from "@std/yaml";
import {
type BackendConfig,
GlobalConfig,
lookupBucket,
type MaterializedBucket,
Expand Down Expand Up @@ -55,6 +56,12 @@ export function parseConfig(
"PROJECT_NAME",
"USER_DOMAIN_NAME",
"PROJECT_DOMAIN_NAME",
"CORS_ALLOWED_ORIGINS",
"CORS_ALLOWED_METHODS",
"CORS_ALLOWED_HEADERS",
"CORS_EXPOSED_HEADERS",
"CORS_MAX_AGE",
"CORS_CREDENTIALS",
];

for (const [key, value] of Object.entries(env)) {
Expand Down Expand Up @@ -93,11 +100,67 @@ export function parseConfig(
backend.credentials = {} as Record<string, unknown>;
}
(backend.credentials as Record<string, unknown>)[configKey] = value;
} else if (configKey.startsWith("cors_")) {
if (!backend.cors) {
backend.cors = {} as Record<string, unknown>;
}
const corsKey = configKey.substring(5);
const camelCorsKey = corsKey.replace(
/_([a-z])/g,
(_, g) => g.toUpperCase(),
);

if (
camelCorsKey === "allowedOrigins" ||
camelCorsKey === "allowedMethods" ||
camelCorsKey === "allowedHeaders" || camelCorsKey === "exposedHeaders"
) {
(backend.cors as Record<string, unknown>)[camelCorsKey] = value.split(
",",
).map((s) => s.trim());
} else if (camelCorsKey === "maxAge") {
const parsed = parseInt(value, 10);
if (Number.isInteger(parsed) && Number.isFinite(parsed)) {
(backend.cors as Record<string, unknown>)[camelCorsKey] = parsed;
}
} else if (camelCorsKey === "credentials") {
(backend.cors as Record<string, unknown>)[camelCorsKey] =
value.toLowerCase() === "true";
}
} else {
backend[configKey] = value;
}
}

// Handle global CORS from env
const globalCors: Record<string, unknown> = (yamlConfig &&
typeof yamlConfig === "object" && "cors" in yamlConfig)
? { ...(yamlConfig as { cors: Record<string, unknown> }).cors }
: {};

for (const [key, value] of Object.entries(env)) {
if (!key.startsWith("HERALD_CORS_")) continue;
const corsKey = key.substring(12).toLowerCase();
const camelCorsKey = corsKey.replace(
/_([a-z])/g,
(_, g) => g.toUpperCase(),
);

if (
camelCorsKey === "allowedOrigins" || camelCorsKey === "allowedMethods" ||
camelCorsKey === "allowedHeaders" || camelCorsKey === "exposedHeaders"
) {
globalCors[camelCorsKey] = value.split(",").map((s) => s.trim());
} else if (camelCorsKey === "maxAge") {
const parsed = parseInt(value, 10);
if (Number.isInteger(parsed) && Number.isFinite(parsed)) {
globalCors[camelCorsKey] = parsed;
}
} else if (camelCorsKey === "credentials") {
globalCors[camelCorsKey] = value.toLowerCase() === "true";
}
}

// Default backend fallback if no backends defined at all
if (Object.keys(backends).length === 0) {
backends["default"] = {
Expand All @@ -106,7 +169,17 @@ export function parseConfig(
};
}

return Schema.decodeUnknownSync(GlobalConfig)({ backends });
const validatedBackends: Record<string, BackendConfig> = {};
for (const [id, b] of Object.entries(backends)) {
if (b.protocol === "s3" || b.protocol === "swift") {
validatedBackends[id] = b as BackendConfig;
}
}

return Schema.decodeUnknownSync(GlobalConfig)({
backends: validatedBackends,
cors: Object.keys(globalCors).length > 0 ? globalCors : undefined,
});
}

export const HeraldConfigLive = Layer.effect(
Expand Down
85 changes: 85 additions & 0 deletions src/Domain/Config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,29 @@ export const SwiftCredentials = Schema.Struct({

export const Credentials = Schema.Union(S3Credentials, SwiftCredentials);

export const CorsConfig = Schema.Struct({
allowedOrigins: Schema.optional(Schema.Array(Schema.String)),
allowedMethods: Schema.optional(Schema.Array(Schema.String)),
allowedHeaders: Schema.optional(Schema.Array(Schema.String)),
exposedHeaders: Schema.optional(Schema.Array(Schema.String)),
maxAge: Schema.optional(Schema.Number),
credentials: Schema.optional(Schema.Boolean),
}).pipe(
Schema.filter((c) => {
if (c.allowedOrigins?.includes("*") && c.credentials) {
return "CORS configuration cannot have allowedOrigins: ['*'] when credentials: true";
}
return true;
}),
);

export type CorsConfig = Schema.Schema.Type<typeof CorsConfig>;

export const BucketOverride = Schema.Struct({
endpoint: Schema.optional(Schema.String),
bucket_name: Schema.optional(Schema.String),
region: Schema.optional(Schema.String),
cors: Schema.optional(CorsConfig),
});

export type BucketOverride = Schema.Schema.Type<typeof BucketOverride>;
Expand All @@ -37,6 +56,7 @@ export const S3Config = Schema.Struct({
region: Schema.optional(Schema.String),
credentials: Schema.optional(S3Credentials),
buckets: BucketsConfig,
cors: Schema.optional(CorsConfig),
});

export const SwiftConfig = Schema.Struct({
Expand All @@ -46,6 +66,7 @@ export const SwiftConfig = Schema.Struct({
container: Schema.optional(Schema.String),
credentials: Schema.optional(SwiftCredentials),
buckets: BucketsConfig,
cors: Schema.optional(CorsConfig),
});

export const BackendConfig = Schema.Union(S3Config, SwiftConfig);
Expand All @@ -54,6 +75,7 @@ export type BackendConfig = Schema.Schema.Type<typeof BackendConfig>;

export const GlobalConfig = Schema.Struct({
backends: Schema.Record({ key: Schema.String, value: BackendConfig }),
cors: Schema.optional(CorsConfig),
});

export type GlobalConfig = Schema.Schema.Type<typeof GlobalConfig>;
Expand Down Expand Up @@ -165,3 +187,66 @@ export const lookupBucket = (

return Option.none();
};

export const resolveCorsConfig = (
config: GlobalConfig,
bucketName: string,
): CorsConfig | undefined => {
// 1. Find the backend and bucket override
let bucketCors: CorsConfig | undefined;
let backendCors: CorsConfig | undefined;

for (const backend of Object.values(config.backends)) {
const buckets = backend.buckets;
if (buckets && typeof buckets !== "string" && buckets[bucketName]) {
bucketCors = buckets[bucketName].cors;
backendCors = backend.cors;
break;
}
}

// If not found by direct hit, try glob match (similar to lookupBucket)
if (!bucketCors && !backendCors) {
for (const backend of Object.values(config.backends)) {
const buckets = backend.buckets;
if (buckets && typeof buckets !== "string") {
let foundMatch = false;
for (const [key, override] of Object.entries(buckets)) {
if (globToRegex(key).test(bucketName)) {
bucketCors = (override as BucketOverride).cors;
backendCors = backend.cors;
foundMatch = true;
break;
}
}
if (foundMatch) break;
}
}
}
Comment thread
Yohe-Am marked this conversation as resolved.

// If still not found, check if it's a general backend match
if (!bucketCors && !backendCors) {
for (const backend of Object.values(config.backends)) {
const buckets = backend.buckets;
if (
typeof buckets === "string" && globToRegex(buckets).test(bucketName)
) {
backendCors = backend.cors;
break;
}
}
}

const globalCors = config.cors;

if (!bucketCors && !backendCors && !globalCors) {
return undefined;
}

// Merge with precedence: bucket > backend > global
return {
...globalCors,
...backendCors,
...bucketCors,
};
};
Loading
Loading