diff --git a/.github/workflows/team-invitations.yml b/.github/workflows/team-invitations.yml new file mode 100644 index 00000000..319d0d41 --- /dev/null +++ b/.github/workflows/team-invitations.yml @@ -0,0 +1,38 @@ +name: Team Invitations +on: + pull_request: + paths: ['applications/team-invitations/**', '.github/workflows/team-invitations.yml'] + push: + paths: ['applications/team-invitations/**', '.github/workflows/team-invitations.yml'] +permissions: + contents: read +jobs: + swift: + runs-on: ubuntu-24.04-arm + timeout-minutes: 20 + defaults: + run: + working-directory: applications/team-invitations + steps: + - uses: actions/checkout@v4 + - name: Install stable native Swift 6.4.0 + run: | + sudo apt-get update + sudo apt-get install -y binutils gnupg2 libc6-dev libcurl4-openssl-dev libedit2 libgcc-13-dev libncurses-dev libpython3-dev libsqlite3-0 libstdc++-13-dev libxml2-dev libz3-dev pkg-config tzdata zip unzip zlib1g-dev + url=https://download.swift.org/swift-6.4.0-release/ubuntu2404-aarch64/swift-6.4.0-RELEASE/swift-6.4.0-RELEASE-ubuntu24.04-aarch64.tar.gz + curl -fsSL --compressed --max-time 600 "$url" -o "$RUNNER_TEMP/swift.tar.gz" + curl -fsSL --compressed --max-time 60 "$url.sig" -o "$RUNNER_TEMP/swift.tar.gz.sig" + curl -fsSL --compressed --max-time 60 https://www.swift.org/keys/all-keys.asc -o "$RUNNER_TEMP/swift-keys.asc" + gpg --import "$RUNNER_TEMP/swift-keys.asc" + gpg --verify "$RUNNER_TEMP/swift.tar.gz.sig" "$RUNNER_TEMP/swift.tar.gz" + tar -xzf "$RUNNER_TEMP/swift.tar.gz" -C "$RUNNER_TEMP" + echo "$RUNNER_TEMP/swift-6.4.0-RELEASE-ubuntu24.04-aarch64/usr/bin" >> "$GITHUB_PATH" + - name: Resolve pinned stable packages + run: | + swift --version + swift package resolve + git diff --exit-code -- Package.resolved + - name: Build and test without Cloud credentials + run: | + swift build --jobs 2 + swift test --jobs 2 diff --git a/applications/team-invitations/.env.example b/applications/team-invitations/.env.example new file mode 100644 index 00000000..8ada9a4b --- /dev/null +++ b/applications/team-invitations/.env.example @@ -0,0 +1,9 @@ +PGHOST=your-managed-postgres-host +PGPORT=5432 +PGDATABASE=postgres +PGUSER=invites_app +PGPASSWORD=replace-with-runtime-role-password +PGSSLROOTCERT=/absolute/path/to/postgres-ca.pem +PORT=3000 +# JSON maps existing seeded user UUIDs to distinct 32–256 character bearer credentials. +USER_TOKENS='{"00000000-0000-4000-8000-000000000001":"replace_with_32_or_more_random_characters"}' diff --git a/applications/team-invitations/.gitignore b/applications/team-invitations/.gitignore new file mode 100644 index 00000000..5ad940ce --- /dev/null +++ b/applications/team-invitations/.gitignore @@ -0,0 +1,6 @@ +.build/ +.swiftpm/ +.deployment/ +.env +*.log +__pycache__/ diff --git a/applications/team-invitations/.swift-version b/applications/team-invitations/.swift-version new file mode 100644 index 00000000..19b860c1 --- /dev/null +++ b/applications/team-invitations/.swift-version @@ -0,0 +1 @@ +6.4.0 diff --git a/applications/team-invitations/Package.resolved b/applications/team-invitations/Package.resolved new file mode 100644 index 00000000..5d968ca4 --- /dev/null +++ b/applications/team-invitations/Package.resolved @@ -0,0 +1,321 @@ +{ + "originHash" : "e8d377ed4a6bc1273b294a584540d80b599b0175b33b6bfd234fdb04ad722779", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "4c005f955e83f888d5616e579717a36d2dfc6301", + "version" : "1.36.2" + } + }, + { + "identity" : "async-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/async-kit.git", + "state" : { + "revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9", + "version" : "1.22.0" + } + }, + { + "identity" : "console-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/console-kit.git", + "state" : { + "revision" : "15121e1ec44a0a2064e43545d1390f02cc19b07d", + "version" : "4.16.1" + } + }, + { + "identity" : "fluent", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent.git", + "state" : { + "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", + "version" : "4.13.0" + } + }, + { + "identity" : "fluent-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-kit.git", + "state" : { + "revision" : "ca609b2132bde05f9a2d7561e2864587c24fa7b9", + "version" : "1.57.0" + } + }, + { + "identity" : "fluent-postgres-driver", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/fluent-postgres-driver.git", + "state" : { + "revision" : "26fe8199666eb0794660dfa0560b75c2ac1e40a8", + "version" : "2.14.0" + } + }, + { + "identity" : "multipart-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/multipart-kit.git", + "state" : { + "revision" : "3498e60218e6003894ff95192d756e238c01f44e", + "version" : "4.7.1" + } + }, + { + "identity" : "postgres-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-kit.git", + "state" : { + "revision" : "139b0c4712d97eaec09d97afd592d394cdb9dbde", + "version" : "2.17.0" + } + }, + { + "identity" : "postgres-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/postgres-nio.git", + "state" : { + "revision" : "39ce38a93408937bcd27f521f3cdf88266136b80", + "version" : "1.33.1" + } + }, + { + "identity" : "routing-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/routing-kit.git", + "state" : { + "revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0", + "version" : "4.9.3" + } + }, + { + "identity" : "sql-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/sql-kit.git", + "state" : { + "revision" : "3779cedb44b1f374f2cca261c6d28f206024a582", + "version" : "3.36.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "3b6410f7dee09eb33cdd26260c5fd47fda19b0e2", + "version" : "1.7.3" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "13713a4ffdee8abd929f92568ee9462a46ae26e0", + "version" : "1.1.7" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "ff86b924ead66f853b8baf91f3c41926a8f36177", + "version" : "1.21.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "98ef3c98609a1e31b7e157b5b619579001a789d6", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "3533f65d3e36dcdffc91ce34ef4d3c9c1887fd4b", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "da9d28d69ebe3894b18376c8f2395c2f37b8448f", + "version" : "4.5.2" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "cc504a45f6ce73ce6067837d7ac19fa67b229a56", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-metrics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-metrics.git", + "state" : { + "revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", + "version" : "2.11.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "21de5f08c1a166a6dd293d0e587ad977bf8dac5d", + "version" : "2.103.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "41449336c8ecfadac6b4b5be75f9c3c306e61ced", + "version" : "1.35.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "0f3e54e29c944c2e835ad52159da7d9e1c94ac69", + "version" : "1.46.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "322f3c2a4a21df31c84ca416bf65ee5e9059e440", + "version" : "2.37.5" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" + } + }, + { + "identity" : "vapor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/vapor.git", + "state" : { + "revision" : "33e61d0a02a9ddc9f025f8398a0dc24774f6cbe1", + "version" : "4.122.2" + } + }, + { + "identity" : "websocket-kit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/vapor/websocket-kit.git", + "state" : { + "revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3", + "version" : "2.16.2" + } + } + ], + "version" : 3 +} diff --git a/applications/team-invitations/Package.swift b/applications/team-invitations/Package.swift new file mode 100644 index 00000000..10479ef5 --- /dev/null +++ b/applications/team-invitations/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version:6.2 +import PackageDescription + +let package = Package( + name: "team-invitations", + products: [.executable(name: "Invitations", targets: ["Run"])], + dependencies: [ + .package(url: "https://github.com/vapor/vapor.git", exact: "4.122.2"), + .package(url: "https://github.com/vapor/fluent.git", exact: "4.13.0"), + .package(url: "https://github.com/vapor/fluent-postgres-driver.git", exact: "2.14.0"), + ], + targets: [ + .target(name: "App", dependencies: [ + .product(name: "Vapor", package: "vapor"), + .product(name: "Fluent", package: "fluent"), + .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), + ]), + .executableTarget(name: "Run", dependencies: [.target(name: "App")]), + .testTarget(name: "AppTests", dependencies: [ + .target(name: "App"), .product(name: "XCTVapor", package: "vapor"), + ]), + ], + swiftLanguageModes: [.v6] +) diff --git a/applications/team-invitations/README.md b/applications/team-invitations/README.md new file mode 100644 index 00000000..7c04e01f --- /dev/null +++ b/applications/team-invitations/README.md @@ -0,0 +1,154 @@ +# Team invitations with Swift and Postgres + +A small JSON API where a seeded team administrator invites an existing seeded user. The recipient accepts a 256-bit secret once, committing an invitation state change and a membership together. Matching acceptance retries return the original membership. This example uses ClickHouse Managed Postgres, Swift 6.4.0, stable Vapor 4.122.2, Fluent 4.13.0, FluentPostgresDriver 2.14.0 and PostgresNIO 1.33.1. Package.resolved pins the full stable dependency graph. Live validation used PostgreSQL 18.6 on native Ubuntu 24.04 ARM64. + +This is an API demonstration with seeded identities and environment bearer credentials. There is no signup, email delivery, email verification, browser session, team creation route or real identity provider. Each credential identifies exactly one existing user through Vapor's native AsyncBearerAuthenticator and guard middleware. The shared database role trusts the API to apply user scope; its grants do not provide independent tenant isolation. The server listens on loopback. + +## Workflow + +- An administrator issues an invitation for a named recipient on their own team. Only SHA256 of the cryptographically random secret is stored. The raw secret appears only in the creation response. Never place it in a URL or log it. If that response is lost, revoke and reissue; creation has no retained request ID. +- Acceptance locks the invitation row, verifies its authenticated recipient and secret, then reads clock_timestamp() after acquiring the lock. Pending invitations expire according to database time. Acceptance updates state and inserts membership using the same Fluent transaction handle. +- Consumed matching retries return the original membership with 200, even after expiry. A new acceptance returns 201; a lost acknowledgment can therefore lead to 200 on retry. A wrong recipient or secret gets 404. Revoked invites get 409; expired pending invites get 410. +- Revoke takes the same row lock. If acceptance commits first, revoke gets 409 and cannot remove membership. If revoke commits first, acceptance gets 409. Repeating revoke returns the revoked metadata. + +Unique(team_id,user_id) prevents duplicate membership independently of API serialization. A deferred composite foreign key ties accepted_membership_id,team_id,recipient_id to that exact membership. It permits state-first insertion inside the transaction and enforces correspondence at commit. Separate invitations to the same recipient/team can race on this unique constraint: one succeeds, the other gets 409 and rolls back. SQLKit is used narrowly for row locks, database time, constraints and grants; all values in operational SQL are bound parameters. + +List routes return explicit DTOs, never database models or digests. They accept limit 1–50 (default 20) and an optional after UUID; results sort by UUID ascending with a strict greater-than cursor. This is a live keyset list, without a snapshot: inserts behind the cursor are not seen on an ongoing traversal. Every list re-applies its administrator or authenticated recipient scope. JSON bodies are limited to 4 KiB, unknown input fields are rejected, and invitation TTL is 1–1440 minutes. + +## Native prerequisites + +Install the official stable [Swift 6.4.0 Linux toolchain](https://www.swift.org/install/linux/ubuntu/24_04/) and its documented Ubuntu 24.04 dependencies on your Linux machine. All installation, package resolution, compilation and tests for this example ran under native /home/al on ARM64; nothing was compiled on macOS. You also need native OpenSSL, GNU timeout and psql for setup/live tests, and the current [clickhousectl CLI](https://github.com/ClickHouse/clickhousectl). + +```bash +cd applications/team-invitations +swift package resolve +swift build --jobs 2 +swift test --jobs 2 +``` + +Builds and unit tests require no database credentials. Keep Package.resolved; no Vapor 5 prerelease enters the graph. + +## Create the Cloud service + +Authenticate clickhousectl with your Cloud organization API credentials. This service is billable; the fixture below selects a modest supported AWS shape and no HA. Use current CLI help if your region or account differs. + +```bash +clickhousectl cloud postgres --org-id YOUR_ORG_ID create --name team-invitations-demo \ + --provider aws --region us-east-1 --size c6gd.large --pg-version 18 --ha-type none \ + --json > postgres-create.json +chmod 600 postgres-create.json +# Save the returned ID and password privately. get never returns the password. +clickhousectl cloud postgres --org-id YOUR_ORG_ID get YOUR_POSTGRES_ID --json +# Repeat get until state is running. +mkdir -p .deployment +chmod 700 .deployment +clickhousectl cloud postgres --org-id YOUR_ORG_ID certs get YOUR_POSTGRES_ID --output .deployment/cloud-ca-bundle.pem +``` + +The original authenticated CA bundle must be retained. The tested bundle contained two roots with the same subject but different keys; full-bundle NIOSSL chain selection failed while OpenSSL verified the endpoint. The setup helper selects exactly one candidate FROM the authenticated bundle that verifies the received leaf and endpoint hostname. It first uses a fully verified OpenSSL handshake bounded by GNU timeout to 20 seconds and refuses zero or multiple matches. It never treats a peer certificate as a trust anchor and does not choose by bundle order. + +```bash +export PGHOST=YOUR_MANAGED_ENDPOINT +export PGPORT=5432 +scripts/select-trust-root.sh .deployment/cloud-ca-bundle.pem .deployment/selected-root.pem +export PGSSLROOTCERT="$PWD/.deployment/selected-root.pem" +export PGDATABASE=postgres PGSSLMODE=verify-full +``` + +Runtime PostgresNIO still requires TLS, full CA and hostname verification, and PGHOST as tlsServerName for SNI. CA rotation requires fetching a fresh authenticated bundle and rerunning selection before restarting. Failed selection or failed TLS must be investigated; there is no trust fallback. The helper is a setup operation, not a background rotation service. + +## Separate schema and runtime roles + +In this setup shell, use the service's returned administrator credentials. Generate distinct strong passwords and retain them in a private passwords.env (exported values), separate from runtime app.env. + +```bash +export PGUSER=postgres +read -rs -p 'Cloud administrator password: ' PGPASSWORD; echo +export PGPASSWORD +export INVITES_MIGRATOR_PASSWORD=$(openssl rand -hex 32) +export INVITES_APP_PASSWORD=$(openssl rand -hex 32) +psql -X -v migrator_password="$INVITES_MIGRATOR_PASSWORD" \ + -v app_password="$INVITES_APP_PASSWORD" -f scripts/bootstrap.sql +export PGUSER=invites_migrator PGPASSWORD="$INVITES_MIGRATOR_PASSWORD" +.build/debug/Invitations migrate --yes +.build/debug/Invitations seed +``` + +bootstrap.sql is intentionally one-time for an empty dedicated service. invites_owner cannot log in; the migrator assumes it only for schema/seed operations. Runtime has SELECT on the four app tables, INSERT on invitations/memberships, only state-transition UPDATE columns on invitations, and UPDATE(id) on teams to permit its scoped row lock. Runtime cannot create tables, change invitation digests/expiry/id/team_id/recipient_id, change team admin_id, delete memberships or create users. Its team id UPDATE grant is trusted for the API’s scoped lock and is not a guarantee that all team identities are immutable. Runtime startup performs no migrations. + +For a disposable fixture, migrate --yes can be repeated; migrate --revert --yes drops all application tables and data, followed by migrate --yes and seed. Do not run destructive reversal on data you need. Repeating seed preserves existing identities and state. + +The seeded users are 00000000-0000-4000-8000-000000000001 through 004. North team a0000000-0000-4000-8000-000000000001 belongs to user 001; South team b0000000-0000-4000-8000-000000000001 belongs to user 002. Administrator identities are fixed in this example. + +## Start and try the API + +Start a **fresh shell** before loading app.env so setup credentials are not inherited. Copy .env.example to a private exported environment file outside source control. Set its endpoint/CA and runtime password. USER_TOKENS maps seeded UUIDs to distinct 32–256 character credentials; generate each with openssl rand -hex 32. The server rejects missing/duplicate/weak mappings and configured users absent from the seed. Treat this as a bounded test authentication scheme. + +```bash +set -a +source /absolute/private/app.env +set +a +cd applications/team-invitations +scripts/run-runtime.sh +``` + +The launcher explicitly passes only runtime fields to the server. It uses two event loops and at most two connections per loop (maximum four overall). Pool acquisition is bounded to 10 seconds; connection setup to 5 seconds and SQL statements to 15 seconds. These are individual limits, not an overall HTTP latency guarantee. Requests that reach a database failure return 503 with a generic message; retry acceptance using the same invitation and secret. + +In another shell, supply only the appropriate bearer credentials. Capture creation privately so its single secret response is not printed or logged: + +```bash +export ADMIN_TOKEN='YOUR_USER_001_CREDENTIAL' +export RECIPIENT_TOKEN='YOUR_USER_003_CREDENTIAL' +umask 077 +curl -fsS -X POST http://127.0.0.1:3000/teams/a0000000-0000-4000-8000-000000000001/invitations \ + -H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \ + -d '{"recipientUserId":"00000000-0000-4000-8000-000000000003","ttlMinutes":60}' > issued.json +# Read invitation.id and secret privately. Send {"secret":"..."} in the POST body: +curl -sS -X POST http://127.0.0.1:3000/invitations/INVITATION_UUID/accept \ + -H "Authorization: Bearer $RECIPIENT_TOKEN" -H 'Content-Type: application/json' \ + --data-binary @acceptance.json +curl -fsS http://127.0.0.1:3000/memberships?limit=20 -H "Authorization: Bearer $RECIPIENT_TOKEN" +# Administrator revocation body is the empty object {}. +``` + +Routes: POST /teams/:id/invitations, POST /invitations/:id/accept, POST /invitations/:id/revoke, GET /teams/:id/invitations and GET /memberships. Authentication is required for all routes. + +## Verify the live fixture + +Use an empty migrated/repeatedly seeded fixture and a running API. In a separate **test shell**, explicitly load runtime endpoint/CA/token fields and the separate migration password. The API process remains runtime-only. + +```bash +set -a; source /absolute/private/app.env; source /absolute/private/passwords.env; set +a +export TEST_MIGRATOR_PASSWORD="$INVITES_MIGRATOR_PASSWORD" +export TEST_RESTART_FIXTURE=/absolute/private/restart-fixture.json +export TEST_EVIDENCE_DIR=/absolute/private/evidence +python3 checks/cloud.py +python3 checks/focused.py +# After that suite, restart the real executable and replay its private fixture: +scripts/process-restart.sh +``` + +The suite checks competing accepts, a real accept/revoke race, matching consumed replay after expiry, a separate SQL row lock held past pending expiry, a forced failure after the invitation state update, independent uniqueness/composite FK checks, forbidden runtime operations, ownership, DTOs and input/list limits. The restart helper must prove the original PID is gone before launching its replacement; its child environments include only runtime credentials. + +The actual factory's positive TLS control is .build/debug/Invitations tls-check. With a valid PEM containing the other official same-name root as PGSSLROOTCERT it must fail; the selected root succeeds. .build/debug/Invitations tls-check --wrong-hostname must also fail, using the same endpoint with a mismatching validation/SNI name. No certificate verification is disabled for these controls. + +## Cleanup + +Stop the API, then delete **only your recorded** service ID. Keep no-HA fixtures only long enough for acceptance testing. + +```bash +clickhousectl cloud postgres --org-id YOUR_ORG_ID delete YOUR_POSTGRES_ID +clickhousectl cloud postgres --org-id YOUR_ORG_ID list --json +# Verify your recorded ID is absent. Postgres delete has no --force option. +``` + +If retaining a dedicated service but removing the example, restore administrator credentials explicitly in a setup shell before any cleanup: + +```bash +export PGUSER=postgres +read -rs -p 'Cloud administrator password: ' PGPASSWORD; echo +export PGPASSWORD PGSSLMODE=verify-full +psql -X -v ON_ERROR_STOP=1 -c 'DROP SCHEMA invites CASCADE; REVOKE invites_owner FROM invites_migrator; DROP OWNED BY invites_app, invites_migrator, invites_owner; DROP ROLE invites_app, invites_migrator, invites_owner;' +``` + +The [Managed Postgres documentation](https://clickhouse.com/docs/products/managed-postgres/overview), [Fluent transaction guide](https://docs.vapor.codes/fluent/transaction/) and [Vapor authentication guide](https://docs.vapor.codes/security/authentication/) explain the underlying services and framework primitives. diff --git a/applications/team-invitations/Sources/App/Auth.swift b/applications/team-invitations/Sources/App/Auth.swift new file mode 100644 index 00000000..44252142 --- /dev/null +++ b/applications/team-invitations/Sources/App/Auth.swift @@ -0,0 +1,30 @@ +import Vapor + +struct Actor: Authenticatable, Sendable { let id: UUID } +struct TokenAuthenticator: AsyncBearerAuthenticator { + let tokens: [UUID: String] + init(json: String) throws { + let input = try JSONDecoder().decode([String: String].self, from: Data(json.utf8)) + guard (1...20).contains(input.count), Set(input.values).count == input.count else { + throw Abort(.internalServerError) + } + var tokens: [UUID: String] = [:] + for (key, value) in input { + guard key.count == 36, let id = UUID(uuidString: key), (32...256).contains(value.utf8.count), + value.utf8.allSatisfy({ + (65...90).contains($0) || (97...122).contains($0) || (48...57).contains($0) || $0 == 45 + || $0 == 95 + }), + tokens[id] == nil + else { throw Abort(.internalServerError) } + tokens[id] = digest(value) + } + self.tokens = tokens + } + func authenticate(bearer: BearerAuthorization, for request: Request) async throws { + let candidate = digest(bearer.token) + for (id, expected) in self.tokens { + if matchingDigest(candidate, expected) { request.auth.login(Actor(id: id)) } + } + } +} diff --git a/applications/team-invitations/Sources/App/Commands.swift b/applications/team-invitations/Sources/App/Commands.swift new file mode 100644 index 00000000..9510ad51 --- /dev/null +++ b/applications/team-invitations/Sources/App/Commands.swift @@ -0,0 +1,47 @@ +import Fluent +import FluentPostgresDriver +import Vapor + +struct SeedCommand: AsyncCommand { + struct Signature: CommandSignature {} + var help: String { "Seed two teams and four users; preserves existing state" } + func run(using context: CommandContext, signature: Signature) async throws { + try await context.application.db.transaction { transaction in + for number in 1...4 { + let id = UUID(uuidString: "00000000-0000-4000-8000-\(String(format: "%012d", number))")! + if try await InviteUser.find(id, on: transaction) == nil { + try await InviteUser(id: id, name: "Demo user \(number)").create(on: transaction) + } + } + for (prefix, admin, name) in [("a", 1, "North team"), ("b", 2, "South team")] { + let id = UUID(uuidString: "\(prefix)0000000-0000-4000-8000-000000000001")! + let owner = UUID(uuidString: "00000000-0000-4000-8000-\(String(format: "%012d", admin))")! + if try await Team.find(id, on: transaction) == nil { + try await Team(id: id, name: name, adminID: owner).create(on: transaction) + } + } + } + context.console.print("Seeded two teams and four users; existing state preserved") + } +} +struct TLSCheckCommand: AsyncCommand { + struct Signature: CommandSignature { + @Flag(name: "wrong-hostname", help: "Negative test only: mismatch certificate name/SNI") + var wrongHostname: Bool + } + var help: String { "Probe the actual runtime Fluent/PostgresNIO TLS connection" } + func run(using context: CommandContext, signature: Signature) async throws { + guard let sql = context.application.db as? any SQLDatabase else { + throw Abort(.internalServerError) + } + let row = try await sql.raw( + "SELECT ssl,version,current_user FROM pg_stat_ssl WHERE pid=pg_backend_pid()" + ).first() + guard let row, try row.decode(column: "ssl", as: Bool.self) else { + throw Abort(.serviceUnavailable) + } + context.console.print( + "Actual Fluent/PostgresNIO path verified: \(try row.decode(column: "version", as: String.self)), role \(try row.decode(column: "current_user", as: String.self))" + ) + } +} diff --git a/applications/team-invitations/Sources/App/Configure.swift b/applications/team-invitations/Sources/App/Configure.swift new file mode 100644 index 00000000..cae2fe71 --- /dev/null +++ b/applications/team-invitations/Sources/App/Configure.swift @@ -0,0 +1,124 @@ +import Fluent +import FluentPostgresDriver +import Vapor + +private func required(_ name: String) throws -> String { + guard let value = Environment.get(name), !value.isEmpty else { + throw Abort(.internalServerError, reason: "Missing configuration") + } + return value +} +public func configure( + _ app: Application, schemaMode: Bool, wrongHostname: Bool = false, tlsProbe: Bool = false +) throws { + app.logger.logLevel = .warning + let user = try required("PGUSER") + guard user == (schemaMode ? "invites_migrator" : "invites_app") else { + throw Abort(.internalServerError) + } + let host = try required("PGHOST") + guard let port = Int(try required("PGPORT")), (1...65535).contains(port) else { + throw Abort(.internalServerError) + } + var tls = TLSConfiguration.makeClientConfiguration() + tls.certificateVerification = .fullVerification + tls.trustRoots = .file(try required("PGSSLROOTCERT")) + var core = PostgresConnection.Configuration( + host: host, port: port, username: user, + password: try required("PGPASSWORD"), database: try required("PGDATABASE"), + tls: .require(try NIOSSLContext(configuration: tls))) + core.options.tlsServerName = wrongHostname ? "mismatch.invalid" : host + core.options.connectTimeout = .seconds(5) + core.options.additionalStartupParameters = [ + ("application_name", "team-invitations"), + ( + "options", + "-c search_path=invites -c timezone=UTC -c statement_timeout=15000" + + (schemaMode ? " -c role=invites_owner" : "") + ), + ] + // Two explicit event loops in entrypoint × two connections per loop = at most four. + app.databases.use( + .postgres( + configuration: SQLPostgresConfiguration(coreConfiguration: core), + maxConnectionsPerEventLoop: 2, connectionPoolTimeout: .seconds(10), sqlLogLevel: .trace), + as: .psql) + app.routes.defaultMaxBodySize = "4kb" + app.http.server.configuration.hostname = "127.0.0.1" + if let value = Environment.get("PORT") { + guard let port = Int(value), (1...65535).contains(port) else { + throw Abort(.internalServerError) + } + app.http.server.configuration.port = port + } else { + app.http.server.configuration.port = 3000 + } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + ContentConfiguration.global.use(encoder: encoder, for: .json) + app.middleware = .init() + app.middleware.use(PublicErrors()) + if schemaMode { + app.migrations.add(InitialSchema()) + app.asyncCommands.use(SeedCommand(), as: "seed") + } else { + app.asyncCommands.use(TLSCheckCommand(), as: "tls-check") + if !tlsProbe { try routes(app) } + } +} +private struct PublicErrors: AsyncMiddleware { + func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response { + do { return try await next.respond(to: request) } catch { + let status: HTTPResponseStatus + if let abort = error as? any AbortError { + status = abort.status + } else if error is DecodingError { + status = .badRequest + } else if let database = error as? any DatabaseError, database.isConstraintFailure { + status = .conflict + } else { + status = .serviceUnavailable + } + struct ErrorBody: Content { let error: String } + let body = ErrorBody( + error: status == .serviceUnavailable ? "Service unavailable" : status.reasonPhrase) + return try await body.encodeResponse(status: status, for: request) + } + } +} +private func routes(_ app: Application) throws { + let authenticator = try TokenAuthenticator(json: required("USER_TOKENS")) + app.lifecycle.use(ValidateConfiguredUsers(ids: Array(authenticator.tokens.keys))) + let scoped = app.grouped(authenticator, Actor.guardMiddleware()) + scoped.post("teams", ":id", "invitations") { request async throws -> Response in + let input = try request.content.decode(IssueInput.self) + return try await issue(request, teamID: pathID(request), input: input).encodeResponse( + status: .created, for: request) + } + scoped.post("invitations", ":id", "accept") { request async throws -> Response in + let input = try request.content.decode(AcceptInput.self) + let (created, member) = try await accept(request, id: pathID(request), input: input) + return try await member.encodeResponse(status: created ? .created : .ok, for: request) + } + scoped.post("invitations", ":id", "revoke") { request async throws -> InvitationView in + _ = try request.content.decode(RevokeInput.self) + return try await revoke(request, id: pathID(request)) + } + scoped.get("teams", ":id", "invitations") { request async throws -> InvitationPage in + try await invitationList(request, teamID: pathID(request), input: PageInput(request)) + } + scoped.get("memberships") { request async throws -> MembershipPage in + try await membershipList(request, input: PageInput(request)) + } +} + +private struct ValidateConfiguredUsers: LifecycleHandler { + let ids: [UUID] + func willBootAsync(_ application: Application) async throws { + for id in ids { + guard try await InviteUser.find(id, on: application.db) != nil else { + throw Abort(.internalServerError, reason: "Configured user missing") + } + } + } +} diff --git a/applications/team-invitations/Sources/App/Inputs.swift b/applications/team-invitations/Sources/App/Inputs.swift new file mode 100644 index 00000000..4384f638 --- /dev/null +++ b/applications/team-invitations/Sources/App/Inputs.swift @@ -0,0 +1,77 @@ +import Vapor + +private struct AnyKey: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} +private func rejectUnknown(_ decoder: any Decoder, allowed: Set) throws { + let keys = try decoder.container(keyedBy: AnyKey.self).allKeys.map(\.stringValue) + guard Set(keys).isSubset(of: allowed) else { + throw Abort(.badRequest, reason: "Unknown input field") + } +} +struct IssueInput: Content, Sendable { + let recipientUserId: UUID + let ttlMinutes: Int + enum CodingKeys: String, CodingKey { case recipientUserId, ttlMinutes } + init(from decoder: any Decoder) throws { + try rejectUnknown(decoder, allowed: ["recipientUserId", "ttlMinutes"]) + let values = try decoder.container(keyedBy: CodingKeys.self) + self.recipientUserId = try values.decode(UUID.self, forKey: .recipientUserId) + self.ttlMinutes = try values.decodeIfPresent(Int.self, forKey: .ttlMinutes) ?? 60 + guard (1...1440).contains(self.ttlMinutes) else { + throw Abort(.badRequest, reason: "TTL must be 1–1440 minutes") + } + } +} +struct AcceptInput: Content, Sendable { + let secret: String + enum CodingKeys: String, CodingKey { case secret } + init(from decoder: any Decoder) throws { + try rejectUnknown(decoder, allowed: ["secret"]) + self.secret = try decoder.container(keyedBy: CodingKeys.self).decode( + String.self, forKey: .secret) + guard + self.secret.utf8.count == 43 + && self.secret.utf8.allSatisfy({ byte in + (65...90).contains(byte) || (97...122).contains(byte) || (48...57).contains(byte) + || byte == 45 || byte == 95 + }) + else { throw Abort(.badRequest, reason: "Secret must be 43 base64url characters") } + } +} +struct RevokeInput: Content, Sendable { + init(from decoder: any Decoder) throws { try rejectUnknown(decoder, allowed: []) } + func encode(to encoder: any Encoder) throws { _ = encoder.container(keyedBy: AnyKey.self) } +} +struct PageInput: Sendable { + let limit: Int + let after: UUID? + init(_ request: Request) throws { + let raw: String? = try request.query.get(at: "limit") + if let raw { + guard !raw.isEmpty, raw.utf8.allSatisfy({ (48...57).contains($0) }), let limit = Int(raw), + (1...50).contains(limit) + else { throw Abort(.badRequest, reason: "Limit must be 1–50") } + self.limit = limit + } else { + self.limit = 20 + } + let cursor: String? = try request.query.get(at: "after") + if let cursor { + guard cursor.count == 36, let after = UUID(uuidString: cursor) else { + throw Abort(.badRequest, reason: "Invalid cursor UUID") + } + self.after = after + } else { + self.after = nil + } + } +} +func pathID(_ request: Request) throws -> UUID { + guard let raw = request.parameters.get("id"), raw.count == 36, let value = UUID(uuidString: raw) + else { throw Abort(.badRequest, reason: "Invalid UUID") } + return value +} diff --git a/applications/team-invitations/Sources/App/Migration.swift b/applications/team-invitations/Sources/App/Migration.swift new file mode 100644 index 00000000..4a759b8f --- /dev/null +++ b/applications/team-invitations/Sources/App/Migration.swift @@ -0,0 +1,59 @@ +import Fluent +import FluentPostgresDriver +import Vapor + +struct InitialSchema: AsyncMigration { + func prepare(on database: any Database) async throws { + try await database.schema("users", space: "invites").id().field("name", .string, .required) + .create() + try await database.schema("teams", space: "invites").id().field("name", .string, .required) + .field("admin_id", .uuid, .required, .references("users", "id")).create() + try await database.schema("memberships", space: "invites").id() + .field("team_id", .uuid, .required, .references("teams", "id")) + .field("user_id", .uuid, .required, .references("users", "id")) + .field("created_at", .datetime, .required) + .unique(on: "team_id", "user_id").unique(on: "id", "team_id", "user_id").create() + try await database.schema("invitations", space: "invites").id() + .field("team_id", .uuid, .required, .references("teams", "id")) + .field("recipient_id", .uuid, .required, .references("users", "id")) + .field("token_digest", .string, .required).unique(on: "token_digest") + .field("status", .string, .required) + .field("created_at", .datetime, .required).field("expires_at", .datetime, .required) + .field("accepted_at", .datetime).field("revoked_at", .datetime) + .field("accepted_membership_id", .uuid).create() + guard let sql = database as? any SQLDatabase else { + throw Abort(.internalServerError, reason: "Postgres SQL database required") + } + // Static constraints/grants complement Fluent's native schema migrations. + let statements: [SQLQueryString] = [ + "ALTER TABLE invites.users ADD CHECK (length(name) BETWEEN 1 AND 80)", + "ALTER TABLE invites.teams ADD CHECK (length(name) BETWEEN 1 AND 80)", + "ALTER TABLE invites.invitations ADD CHECK (length(token_digest)=64 AND token_digest ~ '^[0-9a-f]{64}$')", + "ALTER TABLE invites.invitations ADD CHECK (expires_at > created_at AND expires_at <= created_at + interval '1 day')", + """ + ALTER TABLE invites.invitations ADD CHECK ( + (status='pending' AND accepted_at IS NULL AND revoked_at IS NULL AND accepted_membership_id IS NULL) OR + (status='accepted' AND accepted_at IS NOT NULL AND revoked_at IS NULL AND accepted_membership_id IS NOT NULL) OR + (status='revoked' AND accepted_at IS NULL AND revoked_at IS NOT NULL AND accepted_membership_id IS NULL)) + """, + """ + ALTER TABLE invites.invitations ADD CONSTRAINT accepted_membership_matches_recipient + FOREIGN KEY (accepted_membership_id,team_id,recipient_id) + REFERENCES invites.memberships(id,team_id,user_id) DEFERRABLE INITIALLY DEFERRED + """, + "CREATE INDEX invitations_team_cursor ON invites.invitations(team_id,id)", + "CREATE INDEX memberships_user_cursor ON invites.memberships(user_id,id)", + "GRANT SELECT ON invites.users, invites.teams, invites.invitations, invites.memberships TO invites_app", + "GRANT INSERT ON invites.invitations, invites.memberships TO invites_app", + "GRANT UPDATE (id) ON invites.teams TO invites_app", + "GRANT UPDATE (status,accepted_at,revoked_at,accepted_membership_id) ON invites.invitations TO invites_app", + ] + for statement in statements { try await sql.raw(statement).run() } + } + func revert(on database: any Database) async throws { + try await database.schema("invitations", space: "invites").delete() + try await database.schema("memberships", space: "invites").delete() + try await database.schema("teams", space: "invites").delete() + try await database.schema("users", space: "invites").delete() + } +} diff --git a/applications/team-invitations/Sources/App/Models.swift b/applications/team-invitations/Sources/App/Models.swift new file mode 100644 index 00000000..ba9ee2a7 --- /dev/null +++ b/applications/team-invitations/Sources/App/Models.swift @@ -0,0 +1,108 @@ +import Fluent +import Vapor + +// Models deliberately do not conform to Content: digest/state internals stay private. +final class InviteUser: Model, @unchecked Sendable { + static let schema = "users" + static let space: String? = "invites" + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + init() {} + init(id: UUID, name: String) { + self.id = id + self.name = name + } +} +final class Team: Model, @unchecked Sendable { + static let schema = "teams" + static let space: String? = "invites" + @ID(key: .id) var id: UUID? + @Field(key: "name") var name: String + @Field(key: "admin_id") var adminID: UUID + init() {} + init(id: UUID, name: String, adminID: UUID) { + self.id = id + self.name = name + self.adminID = adminID + } +} +final class Membership: Model, @unchecked Sendable { + static let schema = "memberships" + static let space: String? = "invites" + @ID(key: .id) var id: UUID? + @Field(key: "team_id") var teamID: UUID + @Field(key: "user_id") var userID: UUID + @Field(key: "created_at") var createdAt: Date + init() {} + init(id: UUID, teamID: UUID, userID: UUID, createdAt: Date) { + self.id = id + self.teamID = teamID + self.userID = userID + self.createdAt = createdAt + } +} +final class Invitation: Model, @unchecked Sendable { + static let schema = "invitations" + static let space: String? = "invites" + @ID(key: .id) var id: UUID? + @Field(key: "team_id") var teamID: UUID + @Field(key: "recipient_id") var recipientID: UUID + @Field(key: "token_digest") var tokenDigest: String + @Field(key: "status") var status: String + @Field(key: "created_at") var createdAt: Date + @Field(key: "expires_at") var expiresAt: Date + @OptionalField(key: "accepted_at") var acceptedAt: Date? + @OptionalField(key: "revoked_at") var revokedAt: Date? + @OptionalField(key: "accepted_membership_id") var acceptedMembershipID: UUID? + init() {} + init(teamID: UUID, recipientID: UUID, digest: String, now: Date, expires: Date) { + self.id = UUID() + self.teamID = teamID + self.recipientID = recipientID + self.tokenDigest = digest + self.status = "pending" + self.createdAt = now + self.expiresAt = expires + } +} +struct InvitationView: Content, Sendable { + let id: UUID + let teamID: UUID + let recipientUserID: UUID + let status: String + let createdAt: Date + let expiresAt: Date + init(_ model: Invitation) throws { + self.id = try model.requireID() + self.teamID = model.teamID + self.recipientUserID = model.recipientID + self.status = model.status + self.createdAt = model.createdAt + self.expiresAt = model.expiresAt + } +} +struct MembershipView: Content, Equatable, Sendable { + let id: UUID + let teamID: UUID + let userID: UUID + let createdAt: Date + init(_ model: Membership) throws { + self.id = try model.requireID() + self.teamID = model.teamID + self.userID = model.userID + self.createdAt = model.createdAt + } +} +struct IssuedInvitation: Content, Sendable { + let invitation: InvitationView + // This is the only response that contains the raw invitation secret. + let secret: String +} +struct InvitationPage: Content, Sendable { + let items: [InvitationView] + let nextAfter: UUID? +} +struct MembershipPage: Content, Sendable { + let items: [MembershipView] + let nextAfter: UUID? +} diff --git a/applications/team-invitations/Sources/App/Secrets.swift b/applications/team-invitations/Sources/App/Secrets.swift new file mode 100644 index 00000000..a0d87eda --- /dev/null +++ b/applications/team-invitations/Sources/App/Secrets.swift @@ -0,0 +1,17 @@ +import Vapor + +func digest(_ value: String) -> String { SHA256.hash(data: Data(value.utf8)).hex } +func newInvitationSecret() -> String { + let key = SymmetricKey(size: .bits256) + let bytes = key.withUnsafeBytes { Data($0) } + return bytes.base64EncodedString().replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") +} +func matchingDigest(_ left: String, _ right: String) -> Bool { + let a = Array(left.utf8) + let b = Array(right.utf8) + guard a.count == 64, b.count == 64 else { return false } + var difference: UInt8 = 0 + for index in 0..<64 { difference |= a[index] ^ b[index] } + return difference == 0 +} diff --git a/applications/team-invitations/Sources/App/Workflow.swift b/applications/team-invitations/Sources/App/Workflow.swift new file mode 100644 index 00000000..9927c306 --- /dev/null +++ b/applications/team-invitations/Sources/App/Workflow.swift @@ -0,0 +1,137 @@ +import Fluent +import FluentPostgresDriver +import Vapor + +private func sql(_ database: any Database) throws -> any SQLDatabase { + guard let sql = database as? any SQLDatabase else { throw Abort(.serviceUnavailable) } + return sql +} +private func databaseTime(_ database: any Database) async throws -> Date { + guard let row = try await sql(database).raw("SELECT clock_timestamp() AS now").first() + else { throw Abort(.serviceUnavailable) } + return try row.decode(column: "now", as: Date.self) +} +func issue(_ request: Request, teamID: UUID, input: IssueInput) async throws -> IssuedInvitation { + let actor = try request.auth.require(Actor.self) + let secret = newInvitationSecret() + let tokenDigest = digest(secret) + return try await request.db.transaction { transaction in + // Scope and lock the team before using its immutable administrator identity. + guard + try await sql(transaction).raw( + """ + SELECT id FROM invites.teams + WHERE id=\(bind: teamID) AND admin_id=\(bind: actor.id) FOR UPDATE + """ + ).first() != nil + else { throw Abort(.notFound) } + guard try await InviteUser.find(input.recipientUserId, on: transaction) != nil else { + throw Abort(.notFound) + } + guard + try await Membership.query(on: transaction).filter(\.$teamID == teamID) + .filter(\.$userID == input.recipientUserId).first() == nil + else { throw Abort(.conflict, reason: "Already a team member") } + let now = try await databaseTime(transaction) + let invitation = Invitation( + teamID: teamID, recipientID: input.recipientUserId, digest: tokenDigest, + now: now, expires: now.addingTimeInterval(Double(input.ttlMinutes * 60))) + try await invitation.create(on: transaction) + return try IssuedInvitation(invitation: InvitationView(invitation), secret: secret) + } +} +func accept(_ request: Request, id: UUID, input: AcceptInput) async throws -> (Bool, MembershipView) +{ + let actor = try request.auth.require(Actor.self) + let candidate = digest(input.secret) + return try await request.db.transaction { transaction in + guard + try await sql(transaction).raw( + """ + SELECT id FROM invites.invitations + WHERE id=\(bind: id) AND recipient_id=\(bind: actor.id) FOR UPDATE + """ + ).first() != nil + else { throw Abort(.notFound) } + guard let invitation = try await Invitation.find(id, on: transaction), + matchingDigest(candidate, invitation.tokenDigest) + else { throw Abort(.notFound) } + // Matching consumed replay precedes pending-expiry checks, even after expiry. + if invitation.status == "accepted" { + guard let memberID = invitation.acceptedMembershipID, + let membership = try await Membership.find(memberID, on: transaction) + else { throw Abort(.serviceUnavailable) } + return (false, try MembershipView(membership)) + } + guard invitation.status == "pending" else { + throw Abort(.conflict, reason: "Invitation revoked") + } + // clock_timestamp(), not transaction-start/current app time, after acquiring the lock. + let now = try await databaseTime(transaction) + guard now < invitation.expiresAt else { throw Abort(.gone, reason: "Invitation expired") } + guard + try await Membership.query(on: transaction).filter(\.$teamID == invitation.teamID) + .filter(\.$userID == actor.id).first() == nil + else { throw Abort(.conflict, reason: "Already a team member") } + let membership = Membership( + id: UUID(), teamID: invitation.teamID, userID: actor.id, createdAt: now) + invitation.status = "accepted" + invitation.acceptedAt = now + invitation.acceptedMembershipID = try membership.requireID() + try await invitation.update(on: transaction) + // Deferred composite FK permits state-first insertion; commit enforces same team/recipient. + try await membership.create(on: transaction) + return (true, try MembershipView(membership)) + } +} +func revoke(_ request: Request, id: UUID) async throws -> InvitationView { + let actor = try request.auth.require(Actor.self) + return try await request.db.transaction { transaction in + // Accept and revoke lock exactly the same invitation row; neither locks another invite. + guard + try await sql(transaction).raw( + """ + SELECT i.id FROM invites.invitations AS i + JOIN invites.teams AS t ON t.id=i.team_id + WHERE i.id=\(bind: id) AND t.admin_id=\(bind: actor.id) FOR UPDATE OF i + """ + ).first() != nil + else { throw Abort(.notFound) } + guard let invitation = try await Invitation.find(id, on: transaction) else { + throw Abort(.notFound) + } + guard invitation.status != "accepted" else { + throw Abort(.conflict, reason: "Acceptance cannot be revoked") + } + if invitation.status == "pending" { + invitation.status = "revoked" + invitation.revokedAt = try await databaseTime(transaction) + try await invitation.update(on: transaction) + } + return try InvitationView(invitation) + } +} +func invitationList(_ request: Request, teamID: UUID, input: PageInput) async throws + -> InvitationPage +{ + let actor = try request.auth.require(Actor.self) + guard + try await Team.query(on: request.db).filter(\.$id == teamID).filter(\.$adminID == actor.id) + .first() != nil + else { throw Abort(.notFound) } + let query = Invitation.query(on: request.db).filter(\.$teamID == teamID).sort(\.$id).limit( + input.limit + 1) + if let after = input.after { query.filter(\.$id > after) } + let models = try await query.all() + let items = try models.prefix(input.limit).map(InvitationView.init) + return InvitationPage(items: items, nextAfter: models.count > input.limit ? items.last?.id : nil) +} +func membershipList(_ request: Request, input: PageInput) async throws -> MembershipPage { + let actor = try request.auth.require(Actor.self) + let query = Membership.query(on: request.db).filter(\.$userID == actor.id).sort(\.$id).limit( + input.limit + 1) + if let after = input.after { query.filter(\.$id > after) } + let models = try await query.all() + let items = try models.prefix(input.limit).map(MembershipView.init) + return MembershipPage(items: items, nextAfter: models.count > input.limit ? items.last?.id : nil) +} diff --git a/applications/team-invitations/Sources/Run/entrypoint.swift b/applications/team-invitations/Sources/Run/entrypoint.swift new file mode 100644 index 00000000..dc5ac45e --- /dev/null +++ b/applications/team-invitations/Sources/Run/entrypoint.swift @@ -0,0 +1,32 @@ +import App +import Vapor + +@main +struct Entrypoint { + static func main() async { + let loops = MultiThreadedEventLoopGroup(numberOfThreads: 2) + var app: Application? + do { + let environment = try Environment.detect() + let created = try await Application.make(environment, .shared(loops)) + app = created + let arguments = CommandLine.arguments + let schemaMode = arguments.contains("migrate") || arguments.contains("seed") + let wrongHostname = arguments.contains("tls-check") && arguments.contains("--wrong-hostname") + try configure( + created, schemaMode: schemaMode, wrongHostname: wrongHostname, + tlsProbe: arguments.contains("tls-check")) + try await created.execute() + try await created.asyncShutdown() + try await loops.shutdownGracefully() + } catch { + // No invitation secrets, tokens, SQL bind values or database credentials in errors. + FileHandle.standardError.write( + Data( + "Invitation command failed; check input, configuration, TLS, roles and database\n".utf8)) + if let app { try? await app.asyncShutdown() } + try? await loops.shutdownGracefully() + exit(1) + } + } +} diff --git a/applications/team-invitations/Tests/AppTests/InputTests.swift b/applications/team-invitations/Tests/AppTests/InputTests.swift new file mode 100644 index 00000000..c38452c6 --- /dev/null +++ b/applications/team-invitations/Tests/AppTests/InputTests.swift @@ -0,0 +1,56 @@ +import Vapor +import XCTest + +@testable import App + +final class InputTests: XCTestCase { + private let decoder = JSONDecoder() + func testIssueBoundsAndUnknownFields() throws { + let recipient = "00000000-0000-4000-8000-000000000003" + let valid = "{\"recipientUserId\":\"\(recipient)\"}" + XCTAssertEqual(try decoder.decode(IssueInput.self, from: Data(valid.utf8)).ttlMinutes, 60) + for suffix in [ + ",\"ttlMinutes\":0", ",\"ttlMinutes\":1441", ",\"ttlMinutes\":1.5", ",\"teamId\":\"forged\"", + ] { + XCTAssertThrowsError( + try decoder.decode( + IssueInput.self, from: Data("{\"recipientUserId\":\"\(recipient)\"\(suffix)}".utf8))) + } + } + func testSecretShapeAndEmptyRevokeDTO() throws { + let secret = String(repeating: "A", count: 43) + XCTAssertNoThrow( + try decoder.decode(AcceptInput.self, from: Data("{\"secret\":\"\(secret)\"}".utf8))) + for invalid in [ + String(repeating: "A", count: 42), String(repeating: "A", count: 44), + String(repeating: "A", count: 42) + "=", String(repeating: "é", count: 43), + ] { + XCTAssertThrowsError( + try decoder.decode(AcceptInput.self, from: Data("{\"secret\":\"\(invalid)\"}".utf8))) + } + XCTAssertNoThrow(try decoder.decode(RevokeInput.self, from: Data("{}".utf8))) + XCTAssertThrowsError(try decoder.decode(RevokeInput.self, from: Data("{\"owner\":1}".utf8))) + } + func testRandomSecretAndDigest() { + let first = newInvitationSecret() + let second = newInvitationSecret() + XCTAssertEqual(first.count, 43) + XCTAssertNotEqual(first, second) + XCTAssertEqual( + digest("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + XCTAssertTrue(matchingDigest(digest(first), digest(first))) + XCTAssertFalse(matchingDigest(digest(first), digest(second))) + XCTAssertFalse(matchingDigest("short", digest(first))) + } + func testCredentialMappingRejectsAmbiguousOrWeakIdentities() throws { + let id = "00000000-0000-4000-8000-000000000001" + XCTAssertThrowsError(try TokenAuthenticator(json: "{}")) + XCTAssertThrowsError(try TokenAuthenticator(json: "{\"\(id)\":\"short\"}")) + let token = String(repeating: "A", count: 32) + let authenticator = try TokenAuthenticator(json: "{\"\(id)\":\"\(token)\"}") + XCTAssertEqual(authenticator.tokens[UUID(uuidString: id)!], digest(token)) + XCTAssertThrowsError( + try TokenAuthenticator( + json: "{\"\(id)\":\"\(token)\",\"00000000-0000-4000-8000-000000000002\":\"\(token)\"}")) + } +} diff --git a/applications/team-invitations/checks/cloud.py b/applications/team-invitations/checks/cloud.py new file mode 100644 index 00000000..9cbe9aed --- /dev/null +++ b/applications/team-invitations/checks/cloud.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Explicit live acceptance suite. Run only against this example's dedicated fixture.""" +import concurrent.futures +import hashlib +import json +import os +import subprocess +import time +import urllib.error +import urllib.request +from pathlib import Path + +BASE = os.environ.get('TEST_BASE_URL', 'http://127.0.0.1:3000') +TOKENS = json.loads(os.environ['USER_TOKENS']) +USERS = [f'00000000-0000-4000-8000-{i:012d}' for i in range(1, 5)] +TEAMS = ['a0000000-0000-4000-8000-000000000001', 'b0000000-0000-4000-8000-000000000001'] + + +def http(method, path, actor=0, data=None, raw=None): + body = raw if raw is not None else (json.dumps(data).encode() if data is not None else None) + headers = {'Content-Type': 'application/json'} + if actor is not None: + headers['Authorization'] = 'Bearer ' + TOKENS[USERS[actor]] + request = urllib.request.Request(BASE + path, data=body, headers=headers, method=method) + try: + with urllib.request.urlopen(request, timeout=25) as response: + return response.status, json.load(response) + except urllib.error.HTTPError as error: + return error.code, json.loads(error.read() or '{}') + + +def database(statement, runtime=False, expect=None): + env = dict(os.environ) + env.update(PGSSLMODE='verify-full') + if not runtime: + env.update(PGUSER='invites_migrator', PGPASSWORD=os.environ['TEST_MIGRATOR_PASSWORD'], PGOPTIONS='-c role=invites_owner -c timezone=UTC') + process = subprocess.run(['psql', '-X', '-At', '-v', 'ON_ERROR_STOP=1', '-v', 'VERBOSITY=verbose', '-c', statement], + env=env, text=True, capture_output=True, timeout=25) + if expect: + assert process.returncode != 0 and expect in process.stderr, f'Expected database SQLSTATE {expect}' + return expect + assert process.returncode == 0, 'Dedicated fixture SQL failed' + return process.stdout.strip() + + +def issued(team, recipient): + status, body = http('POST', f'/teams/{TEAMS[team]}/invitations', team, + {'recipientUserId': USERS[recipient], 'ttlMinutes': 1}) + assert status == 201, f'Issue expected201, got{status}' + identifier = body['invitation']['id'] + secret = body['secret'] + assert len(secret) == 43 + stored = database(f"SELECT token_digest FROM invites.invitations WHERE id='{identifier}'") + assert stored == hashlib.sha256(secret.encode()).hexdigest() and stored != secret + assert 'tokenDigest' not in body['invitation'] and 'secret' not in body['invitation'] + return identifier, secret + + +def acceptance(identifier, secret, actor): + return http('POST', f'/invitations/{identifier}/accept', actor, {'secret': secret}) + + +def datetime_from_iso(value): + from datetime import datetime + return datetime.fromisoformat(value) + + +def locked_expiry(expiry_id, expiry_secret): + env = dict(os.environ, PGUSER='invites_migrator', PGPASSWORD=os.environ['TEST_MIGRATOR_PASSWORD'], PGSSLMODE='verify-full', PGOPTIONS='-c role=invites_owner -c timezone=UTC') + # Set the short expiry AFTER the fixture connection acquired the lock. + # Slow connection setup must not consume the pre-expiry observation window. + locker = subprocess.Popen(['stdbuf', '-oL', 'psql', '-X', '-At', '-v', 'ON_ERROR_STOP=1'], env=env, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + locker.stdin.write(f"BEGIN; SELECT id FROM invites.invitations WHERE id='{expiry_id}' FOR UPDATE; UPDATE invites.invitations SET created_at=clock_timestamp()-interval '1 minute',expires_at=clock_timestamp()+interval '2 seconds' WHERE id='{expiry_id}';\n\\echo LOCKED\nSELECT json_build_object('pre_expiry',clock_timestamp()= 1.9, 'Fixture connection must not consume expiry margin' + assert lock_fact['pre_expiry'], 'HTTP must begin while invitation is still pending and unexpired' + from datetime import datetime, timezone + assert datetime.now(timezone.utc) < datetime.fromisoformat(lock_fact['expires_at']), 'Deadline elapsed before HTTP started' + started = time.monotonic() + status, _ = acceptance(expiry_id, expiry_secret, 3) + elapsed = time.monotonic() - started + assert locker.wait(timeout=5) == 0 and status == 410 and elapsed >= 2.0, f'Expiry wait{elapsed}, status{status}' + assert database(f"SELECT status FROM invites.invitations WHERE id='{expiry_id}'") == 'pending' + assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[1]}' AND user_id='{USERS[3]}'") == '0' + print(f'Pre-expiry lock fact:{lock_fact}; independent row lock held3s across expiry; waiting acceptance410 after{elapsed:.3f}s, no membership passed', flush=True) + + + +def main(): + assert database('SELECT count(*) FROM invites.memberships') == '0', 'Use a fresh seeded fixture' + assert http('GET', '/memberships', None)[0] == 401 + assert http('POST', f'/teams/{TEAMS[0]}/invitations', 1, {'recipientUserId': USERS[2]})[0] == 404 + assert http('POST', f'/teams/{TEAMS[0]}/invitations', 0, {'recipientUserId': USERS[2], 'adminId': USERS[0]})[0] == 400 + assert http('POST', f'/teams/{TEAMS[0]}/invitations', 0, {'recipientUserId': USERS[2], 'ttlMinutes': 0})[0] == 400 + assert http('POST', f'/teams/{TEAMS[0]}/invitations', 0, raw=b'{bad')[0] == 400 + assert http('POST', f'/teams/{TEAMS[0]}/invitations', 0, raw=b' ' * 5000)[0] == 413 + assert http('GET', '/memberships?limit=51', 2)[0] == 400 + assert http('GET', '/memberships?after=invalid', 2)[0] == 400 + print('Auth, forged owner, strict input,4KiB payload and list bounds passed', flush=True) + + identifier, secret = issued(0, 2) + assert acceptance(identifier, secret, 3)[0] == 404 + assert acceptance(identifier, 'A' * 43, 2)[0] == 404 + assert http('POST', f'/invitations/{identifier}/revoke', 1, {})[0] == 404 + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + responses = list(executor.map(lambda _: acceptance(identifier, secret, 2), range(8))) + statuses = [status for status, _ in responses] + assert statuses.count(201) == 1 and statuses.count(200) == 7, f'Unexpected competing accepts {statuses}' + member = responses[0][1] + assert all(body == member for _, body in responses) + assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[0]}' AND user_id='{USERS[2]}'") == '1' + database(f"UPDATE invites.invitations SET created_at=clock_timestamp()-interval '2 minutes',expires_at=clock_timestamp()-interval '1 minute' WHERE id='{identifier}'") + assert acceptance(identifier, secret, 2) == (200, member) + assert acceptance(identifier, 'A' * 43, 2)[0] == 404 + assert http('POST', f'/invitations/{identifier}/revoke', 0, {})[0] == 409 + database(f"INSERT INTO invites.memberships(id,team_id,user_id,created_at) VALUES(gen_random_uuid(),'{TEAMS[0]}','{USERS[2]}',clock_timestamp())", expect='23505') + print(f'Eight simultaneous accepts:{statuses}; one membership, matching expired replay and independent unique23505 passed', flush=True) + + rollback_id, rollback_secret = issued(1, 2) + database("CREATE FUNCTION invites.force_membership_failure() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced failure' USING ERRCODE='P0001'; END $$; CREATE TRIGGER force_membership_failure BEFORE INSERT ON invites.memberships FOR EACH ROW EXECUTE FUNCTION invites.force_membership_failure()") + try: + assert acceptance(rollback_id, rollback_secret, 2)[0] == 503 + assert database(f"SELECT status||':'||(accepted_membership_id IS NULL)::text FROM invites.invitations WHERE id='{rollback_id}'") == 'pending:true' + assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[1]}' AND user_id='{USERS[2]}'") == '0' + finally: + database('DROP TRIGGER force_membership_failure ON invites.memberships; DROP FUNCTION invites.force_membership_failure()') + assert acceptance(rollback_id, rollback_secret, 2)[0] == 201 + print('Forced failure after accepted-state update rolled back invitation and membership; retry201 passed', flush=True) + + race_id, race_secret = issued(0, 3) + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + accept_future = executor.submit(acceptance, race_id, race_secret, 3) + revoke_future = executor.submit(http, 'POST', f'/invitations/{race_id}/revoke', 0, {}) + a, r = accept_future.result()[0], revoke_future.result()[0] + assert (a, r) in [(201, 409), (409, 200)], f'Unexpected accept/revoke outcomes{a,r}' + state = database(f"SELECT status FROM invites.invitations WHERE id='{race_id}'") + assert state == ('accepted' if a == 201 else 'revoked') + assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[0]}' AND user_id='{USERS[3]}'") == ('1' if a == 201 else '0') + print(f'Actual accept/revoke race:{a}/{r}, final{state}; one terminal outcome passed', flush=True) + + expiry_id, expiry_secret = issued(1, 3) + locked_expiry(expiry_id, expiry_secret) + + retained_id, retained_secret = issued(0, 1) + retained_status, retained_member = acceptance(retained_id, retained_secret, 1) + assert retained_status == 201 + fixture = Path(os.environ['TEST_RESTART_FIXTURE']) + fixture.write_text(json.dumps({'id': retained_id, 'secret': retained_secret, 'actor': 1, 'membership': retained_member})) + fixture.chmod(0o600) + wrong_member = database(f"SELECT id FROM invites.memberships WHERE team_id='{TEAMS[1]}' AND user_id='{USERS[2]}'") + database(f"BEGIN; UPDATE invites.invitations SET accepted_membership_id='{wrong_member}' WHERE id='{retained_id}'; COMMIT", expect='23503') + print('Deferred composite accepted-membership FK rejects other team/recipient23503 passed', flush=True) + assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[0]}' AND user_id='{USERS[1]}'") == '1' + for statement in ["CREATE TABLE invites.forbidden(id int)", 'DELETE FROM invites.memberships', + "UPDATE invites.invitations SET token_digest=repeat('0',64)", + 'UPDATE invites.invitations SET expires_at=clock_timestamp()', + 'UPDATE invites.teams SET admin_id=admin_id', "INSERT INTO invites.users VALUES(gen_random_uuid(),'forbidden')"]: + database(statement, runtime=True, expect='42501') + database("UPDATE invites.invitations SET status='broken'", runtime=True, expect='23514') + print('Runtime role:DDL/delete/digest/expiry/admin/user writes42501; invalid state23514 passed', flush=True) + status, page = http('GET', f'/teams/{TEAMS[0]}/invitations?limit=1', 0) + assert status == 200 and len(page['items']) == 1 and page.get('nextAfter') + _, next_page = http('GET', f"/teams/{TEAMS[0]}/invitations?limit=1&after={page['nextAfter']}", 0) + assert len(next_page['items']) == 1 and next_page['items'][0]['id'] != page['items'][0]['id'] + assert http('GET', f'/teams/{TEAMS[0]}/invitations', 1)[0] == 404 + _, members = http('GET', '/memberships', 2) + assert len(members['items']) == 2 and all(item['userID'].lower() == USERS[2] for item in members['items']) + assert all('secret' not in item and 'tokenDigest' not in item for item in page['items']) + print('Scoped keyset list, explicit DTOs and membership scope passed; live suite complete', flush=True) + + +if __name__ == '__main__': + main() diff --git a/applications/team-invitations/checks/focused.py b/applications/team-invitations/checks/focused.py new file mode 100644 index 00000000..dfd8bfb5 --- /dev/null +++ b/applications/team-invitations/checks/focused.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +"""Focused followups after cloud.py on the same dedicated fixture.""" +import concurrent.futures +from cloud import issued, acceptance, database, locked_expiry, TEAMS, USERS + +first_id, first_secret = issued(1, 0) +second_id, second_secret = issued(1, 0) +with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor: + first = executor.submit(acceptance, first_id, first_secret, 0) + second = executor.submit(acceptance, second_id, second_secret, 0) + statuses = [first.result()[0], second.result()[0]] +assert sorted(statuses) == [201, 409], f'Distinct invitation race{statuses}' +assert database(f"SELECT count(*) FROM invites.memberships WHERE team_id='{TEAMS[1]}' AND user_id='{USERS[0]}'") == '1' +assert database(f"SELECT string_agg(status,',' ORDER BY status) FROM invites.invitations WHERE id IN ('{first_id}','{second_id}')") == 'accepted,pending' +print(f'Distinct invitations race:{statuses}; one membership, loser stays pending passed', flush=True) +expiry_id, expiry_secret = issued(1, 3) +locked_expiry(expiry_id, expiry_secret) +print('Focused contention/expiry followups complete', flush=True) diff --git a/applications/team-invitations/checks/restart.py b/applications/team-invitations/checks/restart.py new file mode 100644 index 00000000..ec7735ed --- /dev/null +++ b/applications/team-invitations/checks/restart.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Real process restart with explicit runtime-only child environments.""" +import json +import os +import subprocess +import time +from pathlib import Path +from cloud import http, acceptance + +fixture = json.loads(Path(os.environ['TEST_RESTART_FIXTURE']).read_text()) +evidence = Path(os.environ['TEST_EVIDENCE_DIR']) +evidence.mkdir(parents=True, exist_ok=True) +keys = ['PATH', 'HOME', 'PGHOST', 'PGPORT', 'PGDATABASE', 'PGUSER', 'PGPASSWORD', 'PGSSLROOTCERT', 'USER_TOKENS', 'PORT'] +child_env = {key: os.environ[key] for key in keys if key in os.environ} +for key in ['PGHOST', 'PGUSER', 'PGPASSWORD', 'PGSSLROOTCERT', 'USER_TOKENS']: + assert child_env.get(key), f'Missing runtime field{key}' +assert child_env['PGUSER'] == 'invites_app' + + +def start(number): + log = (evidence / f'restart-api-{number}.txt').open('w') + process = subprocess.Popen(['.build/debug/Invitations', 'serve', '--env', 'production'], env=child_env, + stdout=log, stderr=subprocess.STDOUT) + try: + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + assert process.poll() is None, 'Runtime child failed during readiness' + try: + if http('GET', '/memberships', None)[0] == 401: + break + except OSError: + pass + time.sleep(.1) + else: + raise AssertionError('Runtime readiness deadline exceeded') + environment = Path(f'/proc/{process.pid}/environ').read_bytes() + for name in [b'INVITES_ADMIN_PASSWORD=', b'INVITES_MIGRATOR_PASSWORD=', b'TEST_MIGRATOR_PASSWORD=']: + assert name not in environment, 'Child inherited a setup credential' + assert acceptance(fixture['id'], fixture['secret'], fixture['actor']) == (200, fixture['membership']) + return process, log + except BaseException: + process.terminate() + process.wait(timeout=5) + log.close() + raise + + +def stop(process, log): + process.terminate() + process.wait(timeout=5) + log.close() + assert not Path(f'/proc/{process.pid}').exists(), 'First PID must be gone before replacement starts' + + +first, first_log = start(1) +first_pid = first.pid +stop(first, first_log) +second, second_log = start(2) +try: + assert second.pid != first_pid + print(f'External process restart:{first_pid}→{second.pid}; first gone before second launch; both runtime-only; consumed replay200 original membership', flush=True) +finally: + stop(second, second_log) diff --git a/applications/team-invitations/scripts/bootstrap.sql b/applications/team-invitations/scripts/bootstrap.sql new file mode 100644 index 00000000..2a0610dc --- /dev/null +++ b/applications/team-invitations/scripts/bootstrap.sql @@ -0,0 +1,11 @@ +\set ON_ERROR_STOP on +CREATE ROLE invites_owner NOLOGIN; +CREATE ROLE invites_migrator LOGIN PASSWORD :'migrator_password'; +GRANT invites_owner TO invites_migrator; +CREATE ROLE invites_app LOGIN PASSWORD :'app_password'; +GRANT CONNECT ON DATABASE postgres TO invites_migrator, invites_app; +CREATE SCHEMA invites AUTHORIZATION invites_owner; +REVOKE ALL ON SCHEMA invites FROM PUBLIC; +GRANT USAGE ON SCHEMA invites TO invites_migrator, invites_app; +ALTER ROLE invites_migrator SET search_path = invites; +ALTER ROLE invites_app SET search_path = invites; diff --git a/applications/team-invitations/scripts/process-restart.sh b/applications/team-invitations/scripts/process-restart.sh new file mode 100755 index 00000000..b70ecd3f --- /dev/null +++ b/applications/team-invitations/scripts/process-restart.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +: "${TEST_RESTART_FIXTURE:?Set the private fixture produced by cloud.py}" +: "${TEST_EVIDENCE_DIR:?Set a directory outside the repository}" +python3 checks/restart.py diff --git a/applications/team-invitations/scripts/run-runtime.sh b/applications/team-invitations/scripts/run-runtime.sh new file mode 100755 index 00000000..e6596db5 --- /dev/null +++ b/applications/team-invitations/scripts/run-runtime.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +# Load a runtime-only app.env in a fresh shell before calling this script. +exec env -i PATH="$PATH" HOME="$HOME" \ + PGHOST="$PGHOST" PGPORT="$PGPORT" PGDATABASE="$PGDATABASE" \ + PGUSER="$PGUSER" PGPASSWORD="$PGPASSWORD" PGSSLROOTCERT="$PGSSLROOTCERT" \ + USER_TOKENS="$USER_TOKENS" PORT="${PORT:-3000}" \ + .build/debug/Invitations serve --env production diff --git a/applications/team-invitations/scripts/select-trust-root.sh b/applications/team-invitations/scripts/select-trust-root.sh new file mode 100755 index 00000000..77cc1022 --- /dev/null +++ b/applications/team-invitations/scripts/select-trust-root.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ "$#" -ne 2 ]; then + printf 'Usage: PGHOST=... PGPORT=5432 select-trust-root.sh authenticated-ca-bundle.pem selected-root.pem\n' >&2 + exit 1 +fi +: "${PGHOST:?Set the managed endpoint hostname}" +: "${PGPORT:?Set the managed endpoint port}" +bundle=$1 +selected=$2 +[ "$bundle" != "$selected" ] || { printf 'Keep the original bundle separately\n' >&2; exit 1; } +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +# The peer is fully verified against the authenticated CLI bundle and the endpoint name. +timeout 20 openssl s_client -starttls postgres -connect "$PGHOST:$PGPORT" -servername "$PGHOST" \ + -CAfile "$bundle" -verify_hostname "$PGHOST" -verify_return_error -showcerts \ + "$work/handshake.txt" 2> "$work/handshake.err" +awk '/-----BEGIN CERTIFICATE-----/{copy=1} copy{print} /-----END CERTIFICATE-----/{exit}' \ + "$work/handshake.txt" > "$work/leaf.pem" +# Candidates are ONLY certificates from the authenticated bundle, never peer certificates. +awk -v dir="$work" '/-----BEGIN CERTIFICATE-----/{n++; file=dir "/candidate-" n ".pem"} file{print > file} /-----END CERTIFICATE-----/{close(file); file=""}' "$bundle" +matching=0 +anchor= +for candidate in "$work"/candidate-*.pem; do + [ -f "$candidate" ] || continue + if openssl verify -CAfile "$candidate" -verify_hostname "$PGHOST" "$work/leaf.pem" >/dev/null 2>&1; then + matching=$((matching + 1)) + anchor=$candidate + fi +done +[ "$matching" -eq 1 ] || { printf 'Expected exactly one matching downloaded trust anchor; found %s\n' "$matching" >&2; exit 1; } +umask 077 +cp "$anchor" "$selected" +chmod 600 "$selected" +printf 'Selected one verified downloaded trust anchor: ' +openssl x509 -in "$selected" -noout -fingerprint -sha256