Skip to content
Open
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ ui/test-results/
ui/playwright-report/
src/EzOdata.Host/wwwroot/
.DS_Store
deploy/compose/specs/*
!deploy/compose/specs/.gitkeep
100 changes: 100 additions & 0 deletions deploy/compose/demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# Beat 2 of the demo: two unlike engines, one identical API contract.
#
# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build
# ./demo.sh
set -euo pipefail
API=http://localhost:8080
EMAIL=admin@example.com
PASS='a-strong-password-1'

say() { printf '\n\033[1;36m== %s\033[0m\n' "$*"; }

say "Wait for the platform"
for i in $(seq 1 60); do curl -fsS "$API/health" >/dev/null 2>&1 && break || sleep 2; done

say "First-run setup (ignore 409 if already done)"
curl -fsS -X POST "$API/system/setup" -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"displayName\":\"Admin\",\"password\":\"$PASS\"}" >/dev/null 2>&1 || true

say "Log in"
TOKEN=$(curl -fsS -X POST "$API/system/auth/login" -H 'Content-Type: application/json' \
-d "{\"email\":\"$EMAIL\",\"password\":\"$PASS\"}" | jq -r .accessToken)
AUTH=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')

say "Register service 1 of 2 -> PostgreSQL"
curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{
"name":"sales-pg","label":"Sales (PostgreSQL)","connectorType":"postgresql",
"connection":{"host":"sampledb","port":5432,"database":"northwind",
"username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \
| jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)'

say "Register service 2 of 2 -> MySQL (different engine, same platform)"
curl -fsS -X POST "$API/system/services" "${AUTH[@]}" -d '{
"name":"sales-my","label":"Sales (MySQL)","connectorType":"mysql",
"connection":{"host":"sampledb-mysql","port":3306,"database":"northwind",
"username":"demo","password":"demo-pw","tls":{"mode":"disable"}}}' \
| jq -c '{name,connectorType,status}' 2>/dev/null || echo ' (already registered)'

say "Refresh schema on both (introspection) — wait for Active"
for svc in sales-pg sales-my; do
ID=$(curl -fsS "$API/system/services" "${AUTH[@]}" | jq -r --arg n "$svc" '.[]|select(.name==$n)|.id')
curl -fsS -X POST "$API/system/services/$ID/refresh" "${AUTH[@]}" >/dev/null 2>&1 || true
ST=""
for i in $(seq 1 40); do
ST=$(curl -fsS "$API/system/services/$ID" "${AUTH[@]}" | jq -r .status)
case "$ST" in Active|Failed) break;; esac
sleep 2
done
printf ' %-9s id=%-3s status=%s\n' "$svc" "$ID" "$ST"
done

say "THE POINT: byte-for-byte identical OData query against both engines"
for svc in sales-pg sales-my; do
printf '\n--- %s ---\n' "$svc"
curl -fsS -G "$API/api/odata/$svc/customers" "${AUTH[@]}" \
--data-urlencode '$filter=country eq '"'"'US'"'"'' \
--data-urlencode '$select=id,name,country' \
--data-urlencode '$orderby=id' \
--data-urlencode '$top=3' | jq -c '.value'
done

say "Same data over REST, and each service publishes OpenAPI 3.1"
for svc in sales-pg sales-my; do
printf ' %-9s rest: %s | openapi paths: %s\n' "$svc" \
"$(curl -fsS -G "$API/api/rest/$svc/_table/customers" "${AUTH[@]}" --data-urlencode 'limit=1' 2>/dev/null | jq -c '.resource[0].name' 2>/dev/null || echo n/a)" \
"$(curl -fsS "$API/api/odata/$svc/openapi.json" "${AUTH[@]}" 2>/dev/null | jq '.paths|length' 2>/dev/null || echo n/a)"
done

say "Beat 3: interrogate the data — discovery endpoints"
SVC=sales-pg
printf ' tables : %s\n' "$(curl -fsS "$API/api/odata/$SVC/" "${AUTH[@]}" | jq -c '[.value[].name]')"
printf ' $metadata : %s bytes of CSDL\n' "$(curl -fsS "$API/api/odata/$SVC/\$metadata" "${AUTH[@]}" | wc -c | tr -d ' ')"
printf ' customers : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|"\(.name):\(.type)"]')"
printf ' PII flagged : %s\n' "$(curl -fsS "$API/api/rest/$SVC/_table/customers/_schema" "${AUTH[@]}" | jq -c '[.fields[]|select(.description!=null)|{name,description}]')"

say "Beat 3b: the same data over MCP (JSON-RPC, for LLM clients)"
ROLE=$(curl -fsS "$API/system/roles" "${AUTH[@]}" | jq -r '.[0].id')
curl -fsS -X POST "$API/system/apps" "${AUTH[@]}" \
-d "{\"name\":\"mcp-explorer\",\"description\":\"demo\",\"roleId\":$ROLE,\"isActive\":true,\"mcpEnabled\":true}" \
>/dev/null 2>&1 || true
APPID=$(curl -fsS "$API/system/apps" "${AUTH[@]}" | jq -r '.[]|select(.name=="mcp-explorer")|.id')
# The full key is returned exactly once, so mint a fresh one each run.
KEY=$(curl -fsS -X POST "$API/system/apps/$APPID/keys" "${AUTH[@]}" -d '{"name":"demo"}' | jq -r .key)
MCP=(-H "X-API-Key: $KEY" -H 'Content-Type: application/json')
printf ' tools for key : %s\n' "$(curl -fsS "$API/mcp/health" -H "X-API-Key: $KEY" | jq -c .toolsAvailableForKey)"
printf ' tools : %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq -c '[.result.tools[].name]')"
printf ' sales-my_query: %s\n' "$(curl -fsS -X POST "$API/mcp" "${MCP[@]}" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"sales-my_query","arguments":{"table":"customers","filter":"country='"'"'US'"'"'","fields":["id","name"],"limit":3}}}' \
| jq -c '.result.content[0].text|fromjson|.rows')"

say "Beat 3c: browsable OpenAPI (Swagger UI on :8081)"
mkdir -p "$(dirname "$0")/specs"
for svc in sales-pg sales-my; do
for kind in odata rest; do
curl -fsS "$API/api/$kind/$svc/openapi.json" "${AUTH[@]}" > "$(dirname "$0")/specs/$kind-$svc.json"
done
done
printf ' exported %s specs -> ./specs/\n' "$(ls "$(dirname "$0")"/specs/*.json | wc -l | tr -d ' ')"
printf ' open: http://localhost:8081 ("Try it out" works — CORS allows :8081)\n'
77 changes: 77 additions & 0 deletions deploy/compose/docker-compose.demo.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Demo overlay on the MINIMAL profile (SQLite system DB = the EF-migration path that
# is actually tested; the Postgres system-DB path uses EnsureCreated and currently
# fails on a SQLite-style filtered index).
#
# Adds BOTH sample engines as connectable services — that is the demo's point:
# two unlike backends, one identical API contract.
#
# docker compose -f docker-compose.minimal.yml -f docker-compose.demo.yml up -d --build
# ./demo.sh
services:
ez-odata-api:
# image runs as non-root ($APP_UID) but the named /data volume is root-owned,
# so SQLite cannot create its file. Demo-only workaround.
user: "root"
environment:
# base compose ships a 31-byte master key; the host requires exactly 32
Encryption__MasterKey: "Y29tcG9zZS1kZXYtbWFzdGVyLWtleS0zMmJ5dGVzISE="
Mcp__Enabled: "true"
# Preflight is anonymous (no X-API-Key), so Swagger UI's origin has to be on the
# instance default list for "Try it out" to work (spec 08 §7).
Cors__DefaultAllowedOrigins__0: "http://localhost:8081"
depends_on:
sampledb:
condition: service_healthy
sampledb-mysql:
condition: service_healthy

sampledb:
image: postgres:16-alpine
environment:
POSTGRES_DB: northwind
POSTGRES_USER: demo
POSTGRES_PASSWORD: demo-pw
ports:
- "55432:5432"
volumes:
- ../../tests/EzOdata.ConformanceTests/Fixtures/northwind.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U demo -d northwind"]
interval: 5s
timeout: 3s
retries: 20

sampledb-mysql:
image: mariadb:11 # MySQL 8.4 defaults to caching_sha2_password, which the
# connector cannot negotiate without AllowPublicKeyRetrieval
environment:
MYSQL_DATABASE: northwind
MYSQL_USER: demo
MYSQL_PASSWORD: demo-pw
MARIADB_ROOT_PASSWORD: root-pw
MYSQL_ROOT_PASSWORD: root-pw
ports:
- "53306:3306"
volumes:
- ../../js/test/conformance/fixtures/northwind-mysql.sql:/docker-entrypoint-initdb.d/01-northwind.sql:ro
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -proot-pw || mysqladmin ping -h 127.0.0.1 -uroot -proot-pw"]
interval: 5s
timeout: 3s
retries: 20

# Spec browser for the demo. The API requires auth on openapi.json, so Swagger UI
# cannot fetch it directly; ./demo.sh exports the four specs into ./specs and
# nginx serves them from there. Browsing works; "Try it out" needs CORS on the API.
swagger-ui:
image: swaggerapi/swagger-ui
ports:
- "8081:8080"
environment:
URLS: |
[{"url":"/specs/odata-sales-pg.json","name":"OData - sales-pg (PostgreSQL)"},
{"url":"/specs/odata-sales-my.json","name":"OData - sales-my (MySQL)"},
{"url":"/specs/rest-sales-pg.json","name":"REST - sales-pg (PostgreSQL)"},
{"url":"/specs/rest-sales-my.json","name":"REST - sales-my (MySQL)"}]
volumes:
- ./specs:/usr/share/nginx/html/specs:ro
Empty file added deploy/compose/specs/.gitkeep
Empty file.
3 changes: 2 additions & 1 deletion src/EzOdata.Admin/Auth/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ private static string GenerateOpaqueToken() =>
Convert.ToBase64String(RandomNumberGenerator.GetBytes(48))
.Replace('+', '-').Replace('/', '_').TrimEnd('=');

internal static string Sha256(string value) =>
/// <summary>Key/token hash format, shared with CORS origin resolution in the host.</summary>
public static string Sha256(string value) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
}
13 changes: 13 additions & 0 deletions src/EzOdata.Host/Middleware/CorsOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace EzOdata.Host.Middleware;

/// <summary>spec 08 §7 / spec 12 §4 — instance-level CORS defaults.</summary>
public sealed class CorsOptions
{
public const string SectionPath = "Cors";

/// <summary>
/// Origins allowed when the request presents no API key — notably browser preflight,
/// which never carries custom headers (spec 08 §7).
/// </summary>
public string[] DefaultAllowedOrigins { get; set; } = [];
}
181 changes: 181 additions & 0 deletions src/EzOdata.Host/Middleware/DynamicCorsMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
using System.Text.Json;
using EzOdata.Admin.Auth;
using EzOdata.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;

namespace EzOdata.Host.Middleware;

/// <summary>
/// Default-deny CORS (spec 08 §7). The allowed-origin list is dynamic — it comes from the
/// App row behind the presented API key — so this replaces the static UseCors policy.
/// Preflight carries no custom headers, so an unkeyed request falls back to the
/// instance-level <c>Cors:DefaultAllowedOrigins</c> list.
/// Runs before authentication: a preflight must succeed while still anonymous.
/// </summary>
public sealed class DynamicCorsMiddleware
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(60);

// OData + RateLimit headers must be readable by browser clients (spec 08 §7).
private const string ExposedHeaders =
"OData-Version,RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset,Retry-After,X-Request-Id";

private const string AllowedMethods = "GET,POST,PATCH,PUT,DELETE,OPTIONS";
private const string FallbackAllowedHeaders = "Authorization,Content-Type,X-API-Key,Prefer";

private readonly RequestDelegate _next;
private readonly IMemoryCache _cache;
private readonly IServiceScopeFactory _scopeFactory;
private readonly CorsOptions _options;

public DynamicCorsMiddleware(
RequestDelegate next, IMemoryCache cache, IServiceScopeFactory scopeFactory, IOptions<CorsOptions> options)
{
_next = next;
_cache = cache;
_scopeFactory = scopeFactory;
_options = options.Value;
}

public async Task InvokeAsync(HttpContext context)
{
var origin = context.Request.Headers.Origin.ToString();
if (string.IsNullOrEmpty(origin))
{
await _next(context); // same-origin or non-browser: nothing to negotiate
return;
}

var allowed = await ResolveAllowedOriginsAsync(context);
var isPreflight = HttpMethods.IsOptions(context.Request.Method) &&
!string.IsNullOrEmpty(context.Request.Headers.AccessControlRequestMethod);

if (!Matches(origin, allowed))
{
// Default deny: emit no CORS headers. The browser blocks it; a preflight
// is still answered 204 so the failure surfaces as CORS, not as a 401.
if (isPreflight)
{
context.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}

await _next(context);
return;
}

var headers = context.Response.Headers;
headers.AccessControlAllowOrigin = origin;
headers.Append("Vary", "Origin");
headers.AccessControlExposeHeaders = ExposedHeaders;

if (isPreflight)
{
var requested = context.Request.Headers.AccessControlRequestHeaders.ToString();
headers.AccessControlAllowMethods = AllowedMethods;
headers.AccessControlAllowHeaders =
string.IsNullOrEmpty(requested) ? FallbackAllowedHeaders : requested;
headers.AccessControlMaxAge = "600";
context.Response.StatusCode = StatusCodes.Status204NoContent;
return; // never let a preflight reach authentication
}

await _next(context);
}

private async Task<IReadOnlyList<string>> ResolveAllowedOriginsAsync(HttpContext context)
{
var key = context.Request.Headers[ApiKeyAuthenticationOptions.HeaderName].ToString();
if (string.IsNullOrEmpty(key))
{
key = context.Request.Query[ApiKeyAuthenticationOptions.QueryName].ToString();
}

if (string.IsNullOrEmpty(key))
{
return _options.DefaultAllowedOrigins;
}

var hash = AuthService.Sha256(key);
var cacheKey = "ez:cors:" + hash;
if (_cache.TryGetValue(cacheKey, out string[]? cached) && cached is not null)
{
return cached;
}

var origins = await LookupAsync(hash, context.RequestAborted);
_cache.Set(cacheKey, origins, CacheTtl);
return origins;
}

private async Task<string[]> LookupAsync(string hash, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<SystemDbContext>();

var json = await db.ApiKeys
.AsNoTracking()
.Where(k => k.KeyHash == hash && k.RevokedAt == null && k.App!.IsActive)
.Select(k => k.App!.AllowedOriginsJson)
.FirstOrDefaultAsync(ct);

if (string.IsNullOrWhiteSpace(json))
{
// Known key with no per-app list, or an unknown key: fall back to the
// instance default rather than leaking which keys exist.
return _options.DefaultAllowedOrigins;
}

try
{
return JsonSerializer.Deserialize<string[]>(json) ?? _options.DefaultAllowedOrigins;
}
catch (JsonException)
{
return _options.DefaultAllowedOrigins;
}
}

/// <summary>Exact match, or a single leading wildcard label (<c>https://*.example.com</c>).</summary>
public static bool Matches(string origin, IReadOnlyList<string> allowed)
{
foreach (var candidate in allowed)
{
if (string.IsNullOrWhiteSpace(candidate))
{
continue;
}

if (string.Equals(candidate, origin, StringComparison.OrdinalIgnoreCase))
{
return true;
}

var star = candidate.IndexOf("://*.", StringComparison.Ordinal);
if (star < 0)
{
continue;
}

// "https://*.example.com" -> scheme "https://", suffix ".example.com"
var scheme = candidate[..(star + 3)];
var suffix = candidate[(star + 4)..];
if (!origin.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) ||
!origin.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
continue;
}

// Require a non-empty label in place of the wildcard, and no extra dots in it.
var label = origin[scheme.Length..^suffix.Length];
if (label.Length > 0 && !label.Contains('.'))
{
return true;
}
}

return false;
}
}
Loading
Loading