From 2884e65aff29e766e8dccdba846442c34d9d00ad Mon Sep 17 00:00:00 2001 From: isaaguilar Date: Mon, 26 Jan 2026 22:58:03 -0500 Subject: [PATCH 01/11] feat(multi-tenant): beecd OSSaaS multi-tenant ready --- .github/workflows/build.yml | 271 ++ Cargo.toml | 8 +- README.md | 227 +- agent/Dockerfile | 3 + build-fast.sh | 22 +- deploy/helm/beecd/templates/hive-hq.yaml | 6 +- deploy/helm/beecd/templates/hive-server.yaml | 8 +- deploy/helm/beecd/templates/secrets.yaml | 61 +- deploy/helm/beecd/values-example.yaml | 9 +- deploy/helm/beecd/values.yaml | 127 +- hack/setup-dev-db.sh | 52 +- hive-hq/Dockerfile | 4 + hive-hq/api/Cargo.toml | 3 + hive-hq/api/src/auth_tests.rs | 4 + hive-hq/api/src/handler.rs | 2912 ++++++++++++++--- hive-hq/api/src/lib.rs | 6 + hive-hq/api/src/main.rs | 26 +- hive-hq/api/tests/acl_tests.rs | 15 +- hive-hq/api/tests/api_integration_tests.rs | 23 +- hive-hq/api/tests/error_path_tests.rs | 29 +- hive-hq/api/tests/http_api_tests.rs | 9 +- hive-hq/api/tests/integration/test_env.rs | 113 +- hive-hq/api/tests/integration_tests.rs | 49 +- hive-hq/types/src/lib.rs | 1 + hive-hq/ui/src/App.tsx | 8 +- hive-hq/ui/src/components/Alert.tsx | 8 +- hive-hq/ui/src/layouts/MainLayout.tsx | 27 +- hive-hq/ui/src/pages/ClusterDetailPage.tsx | 124 +- hive-hq/ui/src/pages/LoginPage.tsx | 242 +- hive-hq/ui/src/pages/RegisterPage.tsx | 182 ++ hive-hq/ui/src/pages/ReposPage.tsx | 10 +- hive-hq/ui/src/pages/SettingsPage.tsx | 280 ++ hive-hq/ui/src/pages/index.ts | 2 + hive/Cargo.toml | 3 + hive/Dockerfile | 4 + hive/migrations/00000000000000_init.sql | 966 ++++++ .../20260116000001_initial_schema.sql | 472 --- hive/src/auth.rs | 131 +- hive/src/main.rs | 1223 ++++--- hive/tests/approved_releases_tests.rs | 16 +- hive/tests/auth_integration_tests.rs | 8 +- hive/tests/common/mod.rs | 44 +- hive/tests/database_tests.rs | 21 +- hive/tests/grpc_service_tests.rs | 22 +- 44 files changed, 5878 insertions(+), 1903 deletions(-) create mode 100644 .github/workflows/build.yml create mode 100644 hive-hq/ui/src/pages/RegisterPage.tsx create mode 100644 hive-hq/ui/src/pages/SettingsPage.tsx create mode 100644 hive/migrations/00000000000000_init.sql delete mode 100644 hive/migrations/20260116000001_initial_schema.sql diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..da94765 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,271 @@ +name: Build and Push Images + +on: + pull_request: + branches: [master] + push: + branches: [master] + +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }} + +jobs: + # ============================================================================ + # Pre-checks: Validate commits and version + # ============================================================================ + pre-checks: + runs-on: ubuntu-latest + outputs: + skip_build: ${{ steps.check-commits.outputs.skip_build }} + version: ${{ steps.version.outputs.version }} + is_release: ${{ steps.check-event.outputs.is_release }} + image_prefix: ${{ steps.lowercase.outputs.image_prefix }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Convert repository owner to lowercase + id: lowercase + run: | + echo "image_prefix=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}" >> $GITHUB_OUTPUT + + - name: Check event type + id: check-event + run: | + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/master" ]]; then + echo "is_release=true" >> $GITHUB_OUTPUT + else + echo "is_release=false" >> $GITHUB_OUTPUT + fi + + - name: Validate commit messages (commitlint style) + id: commitlint + run: | + # Get commits to check + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + COMMITS=$(git log --format="%s" origin/master..HEAD) + else + # On push, check the pushed commit + COMMITS=$(git log --format="%s" -1) + fi + + # Conventional commit regex + PATTERN="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+\))?!?: .+" + + echo "Checking commits..." + FAILED=false + while IFS= read -r msg; do + if [[ -z "$msg" ]]; then + continue + fi + # Skip merge commits + if [[ "$msg" =~ ^Merge ]]; then + echo "Skipping merge commit: $msg" + continue + fi + if [[ ! "$msg" =~ $PATTERN ]]; then + echo "Invalid commit message: $msg" + echo "Expected format: type(scope)?: description" + echo "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert" + FAILED=true + else + echo "Valid: $msg" + fi + done <<< "$COMMITS" + + if [[ "$FAILED" == "true" ]]; then + echo "Commit message validation failed" + exit 1 + fi + + - name: Check if docs-only commits + id: check-commits + run: | + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + COMMITS=$(git log --format="%s" origin/master..HEAD) + else + COMMITS=$(git log --format="%s" -1) + fi + + # Check if ALL commits are docs-prefixed + ALL_DOCS=true + while IFS= read -r msg; do + if [[ -z "$msg" ]]; then + continue + fi + if [[ ! "$msg" =~ ^docs ]]; then + ALL_DOCS=false + break + fi + done <<< "$COMMITS" + + if [[ "$ALL_DOCS" == "true" ]]; then + echo "All commits are docs-only, skipping build" + echo "skip_build=true" >> $GITHUB_OUTPUT + else + echo "skip_build=false" >> $GITHUB_OUTPUT + fi + + - name: Get version from Cargo.toml + id: version + run: | + VERSION=$(grep -E '^version' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Check version differs from master (PR only) + if: github.event_name == 'pull_request' + run: | + PR_VERSION=$(grep -E '^version' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + + git fetch origin master + git checkout origin/master -- Cargo.toml + MASTER_VERSION=$(grep -E '^version' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + git checkout HEAD -- Cargo.toml + + echo "PR version: $PR_VERSION" + echo "Master version: $MASTER_VERSION" + + if [[ "$PR_VERSION" == "$MASTER_VERSION" ]]; then + echo "Version must be bumped from master version ($MASTER_VERSION)" + exit 1 + fi + + - name: Check breaking change requires major bump (PR only) + if: github.event_name == 'pull_request' + run: | + COMMITS=$(git log --format="%s" origin/master..HEAD) + + HAS_BREAKING=false + while IFS= read -r msg; do + # Check for breaking change indicator (! before :) or BREAKING CHANGE in body + if [[ "$msg" =~ !: ]] || [[ "$msg" =~ BREAKING\ CHANGE ]]; then + HAS_BREAKING=true + break + fi + done <<< "$COMMITS" + + if [[ "$HAS_BREAKING" == "true" ]]; then + echo "Breaking change detected in commits" + + PR_VERSION=$(grep -E '^version' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + + git fetch origin master + git checkout origin/master -- Cargo.toml + MASTER_VERSION=$(grep -E '^version' Cargo.toml | head -1 | sed -E 's/version = "(.*)"/\1/') + git checkout HEAD -- Cargo.toml + + # Extract major versions + PR_MAJOR=$(echo "$PR_VERSION" | cut -d. -f1) + MASTER_MAJOR=$(echo "$MASTER_VERSION" | cut -d. -f1) + + echo "PR major: $PR_MAJOR, Master major: $MASTER_MAJOR" + + if [[ "$PR_MAJOR" -le "$MASTER_MAJOR" ]]; then + echo "Breaking change requires major version bump (current: $MASTER_VERSION, PR: $PR_VERSION)" + exit 1 + fi + fi + + # ============================================================================ + # Build images + # ============================================================================ + build: + needs: pre-checks + if: needs.pre-checks.outputs.skip_build != 'true' + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + strategy: + matrix: + service: [agent, hive, hive-hq] + include: + - service: agent + image_name: hive-agent + dockerfile: agent/Dockerfile + - service: hive + image_name: hive-server + dockerfile: hive/Dockerfile + - service: hive-hq + image_name: hive-hq + dockerfile: hive-hq/Dockerfile + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate image tags + id: tags + run: | + VERSION="${{ needs.pre-checks.outputs.version }}" + IS_RELEASE="${{ needs.pre-checks.outputs.is_release }}" + IMAGE="${{ needs.pre-checks.outputs.image_prefix }}/${{ matrix.image_name }}" + + if [[ "$IS_RELEASE" == "true" ]]; then + # Push to master: use version tag and latest + echo "tags=${IMAGE}:${VERSION},${IMAGE}:latest" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> $GITHUB_OUTPUT + else + # PR: use version with timestamp suffix + TIMESTAMP=$(date +"%Y%m%d%H%M%S") + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + TAG="${VERSION}-${TIMESTAMP}-${SHORT_SHA}" + echo "tags=${IMAGE}:${TAG}" >> $GITHUB_OUTPUT + echo "version=${TAG}" >> $GITHUB_OUTPUT + fi + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.tags.outputs.tags }} + build-args: | + BUILD_VERSION=${{ steps.tags.outputs.version }} + IMAGE_SOURCE=https://github.com/${{ github.repository }} + labels: | + org.opencontainers.image.source=https://github.com/${{ github.repository }} + org.opencontainers.image.version=${{ steps.tags.outputs.version }} + org.opencontainers.image.revision=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ============================================================================ + # Create git tag on release + # ============================================================================ + tag-release: + needs: [pre-checks, build] + if: needs.pre-checks.outputs.is_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Create and push tag + run: | + VERSION="v${{ needs.pre-checks.outputs.version }}" + + # Check if tag already exists + if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "Tag $VERSION already exists, skipping" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo "Created tag $VERSION" diff --git a/Cargo.toml b/Cargo.toml index 91e8410..26a2bf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["agent", "hive", "hive-hq/api", "hive-hq/types", "diff", "storage"] [workspace.package] -version = "0.0.1" +version = "0.1.0" edition = "2021" [workspace.dependencies] @@ -99,6 +99,12 @@ quick-xml = "0.37.1" htmlescape = "0.3.1" snailquote = "0.3.1" +# Cryptography +aes-gcm = "0.10" +hkdf = "0.12" +sha2 = "0.10" +getrandom = "0.2" + # Build dependencies tonic-build = "0.11.0" diff --git a/README.md b/README.md index 619044c..72624cb 100644 --- a/README.md +++ b/README.md @@ -1,210 +1,123 @@ # BeeCD -GitOps deployment with approval gates for Kubernetes. Review and approve what deploys before it goes live. - -## What It Does - -- **Approval gates** before any release reaches production -- **Manifest diffs** so you see exactly what will change -- **Multi-cluster control** from a single dashboard -- **Git-driven workflows** (push manifests, approve, done) -- **Release history** so you know what changed and who approved it - -## What You Need - -- Kubernetes 1.27+ -- Helm 3.0+ -- `kubectl` configured for your cluster -- A GitHub Personal Access Token with `repo` scope -- BeeCD images available in a registry your cluster can pull from +Hive mind for your deploys. Because YOLO deploys aren't cute in production. \ +Before anything goes live, you see a diff of what's changing. ## Getting Started -### Install - -#### 1. Create secrets - -```bash -kubectl create namespace beecd - -kubectl create secret generic hive-jwt \ - --namespace beecd \ - --from-literal=JWT_SECRET_KEY="$(openssl rand -base64 48)" - -kubectl create secret generic github-tokens \ - --namespace beecd \ - --from-literal=GHUSER="your-github-username" \ - --from-literal=GHPASS="ghp_your_token_here" - -./hack/generate-sops-gpg-secret.sh --namespace beecd --apply -``` - -#### 2. Install with Helm - -```bash -helm dependency update deploy/helm/beecd - -helm upgrade --install beecd deploy/helm/beecd \ - --namespace beecd \ - --set postgresql.enabled=true \ - --set minio.enabled=true \ - --set hiveServer.jwt.existingSecret=hive-jwt \ - --set hiveServer.github.existingSecret=github-tokens \ - --set hiveServer.sops.existingSecret=sops-gpg \ - --set hiveHq.jwt.secret="$(openssl rand -base64 48)" -``` - -> **Image registry:** The chart defaults to `registry:5000`. If your images are elsewhere, add: -> ``` -> --set image.registry="your-registry.example.com" \ -> --set image.tag="latest" -> ``` - -Wait for pods: -```bash -kubectl get pods -n beecd -w -``` - -You should see (all `1/1 Running`): -- `beecd-hive-server-*` -- `beecd-hive-hq-*` -- `beecd-postgresql-0` -- `beecd-minio-*` - -#### 3. Access the UI - -```bash -kubectl port-forward svc/beecd-hive-hq 8080:80 -n beecd -``` - -Open [http://localhost:8080](http://localhost:8080) in your browser. You should see the Hive HQ login page. +Go to [https://beecd.galleybytes.com](https://beecd.galleybytes.com) and create an account. After that, log in at your tenant URL (e.g., `https://acme.beecd.galleybytes.com`). -#### 4. Create Your Admin User +### Step 1: Configure Secrets -On first access, you need to create the initial admin user: +Before connecting clusters, configure your GitHub token so BeeCD can fetch manifests from your repositories. -1. Click **Register** on the login page -2. Enter a username and password -3. Click **Create Account** - -**Important:** Save your password somewhere secure. There is no password recovery mechanism. The first user created becomes the administrator. - -### First Deployment +1. Go to **Settings** +2. Under **GitHub Token**, enter your GitHub Personal Access Token (needs `repo` scope) +3. Click **Save** -#### Step 1: Connect a cluster (install the agent) +### Step 2: Connect a Cluster -BeeCD does **not** create Kubernetes clusters. A "cluster" in Hive HQ is an **existing** Kubernetes cluster that you connect by installing the BeeCD **agent** into it. +BeeCD deploys to existing Kubernetes clusters. Connect one by installing the BeeCD agent. -1. In Hive HQ, go to **Clusters** > **Add Cluster** +1. Go to **Clusters** -> **Add Cluster** 2. Give it a name and click **Create** -3. Hive HQ will generate an **agent manifest** - copy it -4. Save the manifest to a file (e.g., `agent.yaml`) -5. Apply it to the Kubernetes cluster you want BeeCD to deploy into: +3. Click **Generate Manifest** to get the agent installation YAML +4. Apply it to your Kubernetes cluster: ```bash kubectl apply -f agent.yaml ``` -This can be the same cluster where you installed BeeCD, or a different one. - -**About the generated manifest:** - -The agent manifest includes pre-filled defaults for connecting back to the Hive server: - -- **Hive gRPC Address**: Defaults to `beecd-hive-server.beecd.svc.cluster.local:5180` (in-cluster FQDN, plaintext). If the agent runs in a different cluster, you need to expose the Hive server via Ingress or LoadBalancer and update the address accordingly. -- **Agent Image**: Defaults to the same registry and tag configured in the Helm chart (e.g., `registry:5000/hive-agent:latest`). - -You can override these defaults in `values.yaml`: - -```yaml -hiveHq: - env: - hiveDefaultGrpcServer: "hive.example.com:443" - agentDefaultImage: "ghcr.io/yourorg/hive-agent:v1.0.0" -``` - -Once the agent connects, you will see a recent heartbeat for the cluster under **Clusters**. +Once the agent connects, you'll see a heartbeat timestamp under **Clusters**. -#### Step 2: Add a repository +### Step 3: Add a Repository -1. In Hive HQ, go to **Repositories** > **Add Repository** -2. Paste the GitHub repository URL (e.g., `https://github.com/galleybytes/manifests`) +1. Go to **Repositories** -> **Add Repository** +2. Enter the GitHub repository URL (e.g., `https://github.com/your-org/manifests`) 3. Click **Save** -#### Step 3: Add a branch and service - -A **branch** tells BeeCD which git branch to watch. A **service** defines what manifests to deploy from that branch. - -1. Click on the repository you just added - -2. Click **Add Branch** and name the branch to track (e.g., `main`) - -3. Click **Add Service** to define a deployable unit +### Step 4: Add a Branch and Service - 1. Fill in the service details: +A **branch** tells BeeCD which git branch to watch. A **service** defines what manifests to deploy. - 1. **Name**: A lowercase identifier for the service (e.g., `my-app`) - 2. **Manifest Path Template**: Where to find manifests in the repo. Use placeholders `{cluster}`, `{namespace}`, and `{service}` to organize by deployment target. +1. Click on your repository +2. Click **Add Branch** and enter the branch name (e.g., `main`) +3. Click **Add Service** and fill in: + - **Name**: A lowercase identifier (e.g., `my-app`) + - **Manifest Path Template**: Where to find manifests. Use placeholders `{cluster}`, `{namespace}`, and `{service}`. - Examples: - 1. `k8s/{cluster}/{namespace}/{service}/` - Separate directories per cluster/namespace/service - 2. `{cluster}/{namespace}/{service}.yaml` - Single file per service, organized by namespace + Examples: + - `k8s/{cluster}/{namespace}/{service}/` - Directory per cluster/namespace/service + - `{cluster}/{namespace}/{service}.yaml` - Single file per service 4. Click **Save** -#### Step 4: Create a cluster group and add your cluster to it +### Step 5: Create a Cluster Group -A **Cluster Group** is how you connect services to clusters. Services are assigned to cluster groups, and clusters in that group can deploy those services. +Cluster Groups connect services to clusters. Services are assigned to groups, and clusters in that group can deploy those services. -1. Go to **Cluster Groups** > **Add Group** -2. Give it a name (e.g., `production`) and click **Create** -3. Click on the group you just created +1. Go to **Cluster Groups** -> **Add Group** +2. Name it (e.g., `production`) and click **Create** +3. Click on the group 4. Click **Add Clusters** and select your cluster -5. Click **Add Services** and select the service you created in Step 3 +5. Click **Add Services** and select your service -#### Step 5: Label a namespace for deployment +### Step 6: Register a Namespace -In the Kubernetes cluster you connected in Step 1, label a namespace so BeeCD knows it is allowed to manage it: +Label a namespace in your Kubernetes cluster so BeeCD knows it can manage deployments there: ```bash -kubectl create namespace my-app -kubectl label namespace my-app beecd/register=true +kubectl label namespace my-namespace beecd.io/enabled=true ``` -Within 60 seconds, the namespace will appear in Hive HQ under **Clusters** > your cluster. +The namespace will appear in Hive HQ within 60 seconds under **Clusters** -> your cluster. -#### Step 6: Register the service to the namespace +### Step 7: Add Services to the Namespace -1. In Hive HQ, go to **Clusters** > your cluster -2. Find the `my-app` namespace -3. Click on the namespace to edit it -4. Select the service you want to deploy (only services from cluster groups this cluster belongs to will appear) -5. Click **Save** +1. Go to **Clusters** -> your cluster +2. Find the namespace and click the edit icon +3. Select the services you want to deploy (only services from cluster groups this cluster belongs to will appear) +4. Click **Add** -#### Step 7: Point the release at a commit +### Step 8: Create a Version -Each release needs to know which git commit to deploy. This is called a **version**. You can create one manually: +Each release needs a git commit to deploy. Create a version: -1. Navigate to **Clusters** > select your cluster > scroll down to the **Releases** section -2. Find your release in the list and click on it to open the detail page -3. Click **Versions** > **Add Manual Version** -4. Enter a version tag (e.g., `v1.0.0`) and the git commit SHA from your repo +1. Go to **Clusters** -> your cluster -> **Releases** +2. Click on a release +3. Click **Versions** -> **Add Manual Version** +4. Enter a version tag (e.g., `v1.0.0`) and the git commit SHA 5. Click **Create Version** -BeeCD will pull the manifests from that commit and prepare them for deployment. +BeeCD will fetch the manifests from that commit. -> **Tip:** For production, you can set up GitHub webhooks to create versions automatically when you push changes. See the Helm chart documentation for webhook configuration. - -#### Step 8: Review and approve +### Step 9: Review and Approve 1. Go to **Releases** 2. Click on the pending release -3. Review the **Diff** (shows what will be applied) +3. Review the **Diff** to see what will be applied 4. Click **Approve** -The manifests will deploy to your cluster. You can verify: +The manifests will deploy to your cluster. Verify with: ```bash -kubectl get all -n my-app +kubectl get all -n my-namespace ``` +## Concepts + +| Term | Description | +|------|-------------| +| **Cluster** | A Kubernetes cluster connected via the BeeCD agent | +| **Cluster Group** | A collection of clusters that share the same services | +| **Service** | A deployable unit defined by a manifest path in a git repository | +| **Release** | A service deployed to a specific namespace in a cluster | +| **Version** | A git commit SHA that a release should deploy | + +## Need Help? + +- Submit an issue on GitHub: https://github.com/galleybytes/beecd/issues +- Verify your GitHub token has access to the repository +- Ensure the agent is running: `kubectl get pods -n beecd` +- Check agent logs: `kubectl logs -n beecd -l app=beecd-agent` + diff --git a/agent/Dockerfile b/agent/Dockerfile index 232e235..f3f7662 100644 --- a/agent/Dockerfile +++ b/agent/Dockerfile @@ -106,6 +106,9 @@ RUN cargo build --release --target x86_64-unknown-linux-musl -p agent && \ # ============================================================================= FROM scratch +ARG IMAGE_SOURCE=https://github.com/galleybytes/beecd +LABEL org.opencontainers.image.source=${IMAGE_SOURCE} + COPY --from=builder /etc/ssl/certs /etc/ssl/certs COPY --from=builder /usr/local/bin/agent /usr/local/bin/agent diff --git a/build-fast.sh b/build-fast.sh index 9ba5895..142dbe4 100755 --- a/build-fast.sh +++ b/build-fast.sh @@ -261,10 +261,10 @@ build_binary() { } ####################################### -# Build hive-hq UI and docs (requires npm and zola) +# Build hive-hq UI (requires npm) ####################################### build_hive_hq_assets() { - log_info "Building hive-hq UI and docs..." + log_info "Building hive-hq UI..." # Check for npm if ! command -v npm &>/dev/null; then @@ -272,16 +272,6 @@ build_hive_hq_assets() { exit 1 fi - # Check for zola - if ! command -v zola &>/dev/null; then - log_error "zola not found. Please install: brew install zola" - exit 1 - fi - - # Build docs with zola - log_info "Building docs with zola..." - (cd hive-hq/docs && zola build --output-dir ../dist-docs --force) - # Build UI with npm/vite (React) log_info "Building UI with npm (React/Vite)..." (cd hive-hq/ui && npm ci && npm run build) @@ -290,7 +280,7 @@ build_hive_hq_assets() { rm -rf hive-hq/dist cp -r hive-hq/ui/dist hive-hq/dist - log_success "UI and docs built" + log_success "UI built" } ####################################### @@ -327,13 +317,12 @@ create_docker_image() { local tmp_dockerfile=$(mktemp) if [[ "$service" == "hive-hq" ]]; then - # hive-hq needs UI assets and docs + # hive-hq needs UI assets cat > "$tmp_dockerfile" < -- psql -U postgres -d hive -f /path/to/init.sql + # + # Or via a Kubernetes Job before deploying the application. + # The migration grants DML privileges to hive_user/hq_user at the end. + 00-init-roles.sql: | + -- Create hive database CREATE DATABASE hive; + + -- hive_admin: owns tables and SECURITY DEFINER functions + -- BYPASSRLS allows SECURITY DEFINER functions to bypass RLS + -- Used for migrations, admin tasks, bypasses RLS + CREATE USER hive_admin WITH PASSWORD 'hive-admin-password' BYPASSRLS; + GRANT ALL PRIVILEGES ON DATABASE hive TO hive_admin; + + -- hive_user: app connection role, subject to RLS (non-owner) + -- Used by hive-server and hive-hq application connections CREATE USER hive_user WITH PASSWORD 'hive-password'; - GRANT ALL PRIVILEGES ON DATABASE hive TO hive_user; + GRANT CONNECT ON DATABASE hive TO hive_user; + + -- hq_user: same as hive_user (used by hive-hq API) + CREATE USER hq_user WITH PASSWORD 'hq-password'; + GRANT CONNECT ON DATABASE hive TO hq_user; + + -- Allow admin to act as app user for testing + GRANT hive_user TO hive_admin; + GRANT hq_user TO hive_admin; + \c hive CREATE EXTENSION IF NOT EXISTS pgcrypto; - GRANT ALL ON SCHEMA public TO hive_user; - ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO hive_user; - ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO hive_user; + + -- hive_admin owns the schema + GRANT ALL ON SCHEMA public TO hive_admin; + ALTER SCHEMA public OWNER TO hive_admin; + + -- App users get basic schema access (DML grants come from migration) + GRANT USAGE ON SCHEMA public TO hive_user; + GRANT USAGE ON SCHEMA public TO hq_user; # MinIO subchart configuration # Enable to deploy MinIO with the chart @@ -83,7 +118,55 @@ hiveServer: grpcTimeout: 30 grpcKeepAlive: 20 + # Hive Server configuration +hiveServer: + enabled: true + replicaCount: 1 + + image: + repository: hive-server + tag: "" # Optional: override image tag for this component + + service: + type: ClusterIP + port: 5180 + + resources: {} + # limits: + # cpu: 500m + # memory: 512Mi + # requests: + # cpu: 250m + # memory: 256Mi + + env: + logLevel: info + storageType: s3 + grpcTimeout: 30 + grpcKeepAlive: 20 + + # Crypto configuration for multi-tenant secret encryption + crypto: + # Bootstrap key: 32-byte random key (base64-encoded) used to derive per-tenant encryption keys + # Generate with: openssl rand -base64 32 + # WARNING: This is the master key for all tenant secret decryption - protect it carefully + # RECOMMENDED: Use existingSecret to load from Kubernetes Secret instead of hardcoding + bootstrapKey: "" # Use existingSecret for production + + # Use existing Kubernetes secret to load the bootstrap key + # Secret must contain: HIVE_CRYPTO_ROOT_KEY + # Create with: kubectl create secret generic hive-crypto-bootstrap \ + # --from-literal=HIVE_CRYPTO_ROOT_KEY="$(openssl rand -base64 32)" + existingSecret: "" # Name of secret containing HIVE_CRYPTO_ROOT_KEY + # Database configuration - REQUIRED (auto-configured if postgresql.enabled=true) + # + # Two-role pattern for Row-Level Security: + # - hive_admin: owns tables, runs migrations, bypasses RLS + # - hive_user: app connection role, subject to RLS (non-owner) + # + # The application (hive-server, hive-hq) connects as hive_user. + # Migrations must be run separately as hive_admin or postgres superuser. database: # Hive database (single database for all BeeCD data) hive: @@ -92,14 +175,23 @@ hiveServer: hostRo: "" # Optional read-only replica, defaults to host if empty port: 5432 name: hive + # Application user (subject to RLS - non-owner) user: hive_user password: "hive-password" # Use existingSecret instead for production existingSecret: "" # Name of existing secret containing DATABASE_HOST, DATABASE_PASSWORD, etc. - # GitHub configuration - REQUIRED + # Admin user for running migrations (bypasses RLS - table owner) + # Only needed if running migrations separately from PostgreSQL initdb + admin: + user: hive_admin + password: "hive-admin-password" # Use existingSecret for production + existingSecret: "" # Secret containing ADMIN_DATABASE_USER, ADMIN_DATABASE_PASSWORD + + # GitHub configuration - OPTIONAL (for legacy repo access) + # In multi-tenant mode, GitHub tokens are configured per-tenant via the UI github: - user: "" - token: "" # Personal Access Token with repo scope + user: "" # Optional: for legacy single-tenant mode or backward compatibility + token: "" # Optional: Personal Access Token with repo scope apiUrl: "" # Optional: for GitHub Enterprise existingSecret: "" # Use existing secret instead @@ -121,10 +213,11 @@ hiveServer: # --from-literal=JWT_SECRET_KEY="$(openssl rand -base64 48)" existingSecret: "" - # SOPS/GPG configuration - REQUIRED for encrypted secrets + # SOPS/GPG configuration - OPTIONAL (for legacy single-tenant mode) + # In multi-tenant mode, GPG keys are configured per-tenant via the UI sops: - fingerprint: "" - gpgKey: "" # Base64-encoded GPG private key + fingerprint: "" # Optional: GPG key fingerprint + gpgKey: "" # Optional: Base64-encoded GPG private key existingSecret: "" # Use existing secret instead # Storage configuration (S3 or MinIO) - REQUIRED (auto-configured if minio.enabled=true) @@ -191,7 +284,17 @@ hiveHq: secret: "" # Auto-generated if not provided existingSecret: "" # Use existing secret instead - # Uses hiveServer.database.hive configuration + # Database configuration for hive-hq (uses hq_user, subject to RLS) + database: + # Leave empty to use embedded PostgreSQL (when postgresql.enabled=true) + host: "" # Will default to {{ .Release.Name }}-postgresql if postgresql.enabled + hostRo: "" # Optional read-only replica, defaults to host if empty + port: 5432 + name: hive + # hq_user: app connection role, subject to RLS (non-owner) + user: hq_user + password: "hq-password" # Use existingSecret instead for production + existingSecret: "" # Name of existing secret containing DATABASE_HOST, DATABASE_PASSWORD, etc. # Ingress configuration ingress: diff --git a/hack/setup-dev-db.sh b/hack/setup-dev-db.sh index 201460e..bdf11f8 100755 --- a/hack/setup-dev-db.sh +++ b/hack/setup-dev-db.sh @@ -17,6 +17,8 @@ DB_SUPERPASSWORD=${DB_SUPERPASSWORD:-pass} CRUD_DB=${CRUD_DB:-crud} HIVE_DB=${HIVE_DB:-hive} +HIVE_ADMIN=${HIVE_ADMIN:-hive_admin} +HIVE_ADMIN_PASSWORD=${HIVE_ADMIN_PASSWORD:-adminpass} HIVE_USER=${HIVE_USER:-hive_user} HIVE_PASSWORD=${HIVE_PASSWORD:-pass} @@ -91,11 +93,21 @@ if ! psql_super postgres "SELECT 1 FROM pg_database WHERE datname='$AVERSION_DB' fi # create/update roles +# hive_admin: owns tables and SECURITY DEFINER functions, runs migrations +# BYPASSRLS allows SECURITY DEFINER functions to bypass RLS +if ! psql_super postgres "SELECT 1 FROM pg_roles WHERE rolname='$HIVE_ADMIN'" | grep -q 1; then + psql_super postgres "CREATE USER $HIVE_ADMIN WITH PASSWORD '$HIVE_ADMIN_PASSWORD' BYPASSRLS" +else + psql_super postgres "ALTER ROLE $HIVE_ADMIN WITH PASSWORD '$HIVE_ADMIN_PASSWORD' BYPASSRLS" +fi +# hive_user: app connection role, subject to RLS (non-owner) if ! psql_super postgres "SELECT 1 FROM pg_roles WHERE rolname='$HIVE_USER'" | grep -q 1; then psql_super postgres "CREATE USER $HIVE_USER WITH PASSWORD '$HIVE_PASSWORD'" else psql_super postgres "ALTER ROLE $HIVE_USER WITH PASSWORD '$HIVE_PASSWORD'" fi +# Grant hive_user to hive_admin so admin can test as app user +psql_super postgres "GRANT $HIVE_USER TO $HIVE_ADMIN" 2>/dev/null || true if ! psql_super postgres "SELECT 1 FROM pg_roles WHERE rolname='$HQ_USER'" | grep -q 1; then psql_super postgres "CREATE USER $HQ_USER WITH PASSWORD '$HQ_PASSWORD'" else @@ -108,16 +120,18 @@ else fi # grants for hive db -psql_super postgres "GRANT ALL PRIVILEGES ON DATABASE $HIVE_DB TO $HIVE_USER" +# hive_admin owns the database and schema +psql_super postgres "GRANT ALL PRIVILEGES ON DATABASE $HIVE_DB TO $HIVE_ADMIN" +psql_super "$HIVE_DB" "GRANT ALL ON SCHEMA public TO $HIVE_ADMIN" +psql_super "$HIVE_DB" "ALTER SCHEMA public OWNER TO $HIVE_ADMIN" + +# hive_user gets DML access (NOT ownership - this is key for RLS) +psql_super postgres "GRANT CONNECT ON DATABASE $HIVE_DB TO $HIVE_USER" +psql_super "$HIVE_DB" "GRANT USAGE ON SCHEMA public TO $HIVE_USER" + +# hq_user shares same permissions as hive_user (both are app roles) psql_super postgres "GRANT CONNECT ON DATABASE $HIVE_DB TO $HQ_USER" -psql_super "$HIVE_DB" "GRANT ALL ON SCHEMA public TO $HIVE_USER" -psql_super "$HIVE_DB" "GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO $HIVE_USER" -psql_super "$HIVE_DB" "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO $HIVE_USER" -psql_super "$HIVE_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $HIVE_USER" -psql_super "$HIVE_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $HIVE_USER" -psql_super "$HIVE_DB" "GRANT ALL ON SCHEMA public TO $HQ_USER" -psql_super "$HIVE_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $HQ_USER" -psql_super "$HIVE_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $HQ_USER" +psql_super "$HIVE_DB" "GRANT USAGE ON SCHEMA public TO $HQ_USER" # grants for aversion db psql_super postgres "GRANT ALL PRIVILEGES ON DATABASE $AVERSION_DB TO $AVERSION_USER" @@ -127,7 +141,23 @@ psql_super "$AVERSION_DB" "GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA publi psql_super "$AVERSION_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO $AVERSION_USER" psql_super "$AVERSION_DB" "ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO $AVERSION_USER" -echo "Running initial hive migration..." -PGPASSWORD="$HIVE_PASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$HIVE_USER" -d "$HIVE_DB" -p "$DB_PORT" -f hive/migrations/20241215000001_initial_schema.sql +echo "Running initial hive migration as admin..." +# Migration runs as hive_admin (or superuser) so tables are owned correctly +# The init.sql grants DML privileges to hive_user at the end +PGPASSWORD="$DB_SUPERPASSWORD" psql -v ON_ERROR_STOP=1 -h "$DB_HOST" -U "$DB_SUPERUSER" -d "$HIVE_DB" -p "$DB_PORT" -f hive/migrations/00000000000000_init.sql echo "Done" +echo "" +echo "Role setup:" +echo " hive_admin: owns tables/functions, use for migrations (password: $HIVE_ADMIN_PASSWORD)" +echo " hive_user: app connection role, subject to RLS (password: $HIVE_PASSWORD)" +echo "" +echo "Environment variables for hive-server:" +echo " export DATABASE_HOST=$DB_HOST" +echo " export DATABASE_HOST_RO=$DB_HOST" +echo " export DATABASE_PORT=$DB_PORT" +echo " export DATABASE_NAME=$HIVE_DB" +echo " export DATABASE_USER=$HIVE_USER" +echo " export DATABASE_PASSWORD=$HIVE_PASSWORD" +echo " export DATABASE_ADMIN_USER=$HIVE_ADMIN" +echo " export DATABASE_ADMIN_PASSWORD=$HIVE_ADMIN_PASSWORD" diff --git a/hive-hq/Dockerfile b/hive-hq/Dockerfile index c174846..24e2118 100644 --- a/hive-hq/Dockerfile +++ b/hive-hq/Dockerfile @@ -79,6 +79,10 @@ RUN cargo build --release --target x86_64-unknown-linux-musl -p api && \ # Runtime image FROM scratch + +ARG IMAGE_SOURCE=https://github.com/galleybytes/beecd +LABEL org.opencontainers.image.source=${IMAGE_SOURCE} + COPY --from=builder /etc/ssl/certs /etc/ssl/certs COPY --from=builder /usr/local/bin/hivehq /usr/local/bin/hivehq COPY --from=ui-builder /ui/dist dist diff --git a/hive-hq/api/Cargo.toml b/hive-hq/api/Cargo.toml index 20b04af..52583fa 100644 --- a/hive-hq/api/Cargo.toml +++ b/hive-hq/api/Cargo.toml @@ -40,3 +40,6 @@ regex.workspace = true types = { workspace = true, features = ["api"] } hmac = "0.12" hex = "0.4" +aes-gcm = "0.10" +hkdf = "0.12" +getrandom = "0.2" diff --git a/hive-hq/api/src/auth_tests.rs b/hive-hq/api/src/auth_tests.rs index b97b44f..ed48df1 100644 --- a/hive-hq/api/src/auth_tests.rs +++ b/hive-hq/api/src/auth_tests.rs @@ -17,6 +17,7 @@ mod tests { email: "test@example.com".to_string(), exp: expiration.as_secs() as usize, roles: vec!["admin".to_string(), "aversion".to_string()], + tenant_id: String::new(), }; assert_eq!(claim.roles.len(), 2); @@ -34,6 +35,7 @@ mod tests { email: "admin@galleybytes.com".to_string(), exp: expiration.as_secs() as usize, roles: vec!["admin".to_string()], + tenant_id: String::new(), }; let token = encode( @@ -68,6 +70,7 @@ mod tests { email: "aversion@galleybytes.com".to_string(), exp: expiration.as_secs() as usize, roles: vec!["aversion".to_string()], + tenant_id: String::new(), }; let token = encode( @@ -103,6 +106,7 @@ mod tests { "aversion".to_string(), "operator".to_string(), ], + tenant_id: String::new(), }; let token = encode( diff --git a/hive-hq/api/src/handler.rs b/hive-hq/api/src/handler.rs index 843f14e..02b4fb6 100644 --- a/hive-hq/api/src/handler.rs +++ b/hive-hq/api/src/handler.rs @@ -25,6 +25,184 @@ use utoipa::ToSchema; use uuid::Uuid; +/// Extract tenant_id from Host header +/// Looks up the domain in the tenants table and returns the tenant_id +async fn extract_tenant_from_request( + pool: &sqlx::Pool, + headers: &axum::http::HeaderMap, +) -> Result { + // Prefer X-Forwarded-Host when behind dev proxy; fall back to Host. + // If those are unusable (localhost), try Origin then Referer to recover the tenant subdomain. + let xf_host_name = axum::http::HeaderName::from_static("x-forwarded-host"); + let mut host = headers + .get(&xf_host_name) + .and_then(|h| h.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()) + .or_else(|| { + headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()) + }) + .unwrap_or_else(|| "localhost".to_string()); + + // Extract slug from host (first label before the first dot) + let slug = host + .split(':') + .next() + .unwrap_or(&host) + .split('.') + .next() + .unwrap_or(""); + + tracing::debug!("extract_tenant_from_request: host={}, slug={}", host, slug); + + if slug.is_empty() || slug == "localhost" || slug == "beecd" { + // Try Origin header to recover real host + if let Some(origin_val) = headers + .get(axum::http::header::ORIGIN) + .and_then(|h| h.to_str().ok()) + { + // origin like: http://acme2.localhost:5173 + let origin_host = origin_val + .split("//") + .nth(1) + .map(|s| s.split('/').next().unwrap_or(s)) + .unwrap_or(origin_val); + host = origin_host.to_string(); + } else if let Some(referer_val) = headers + .get(axum::http::header::REFERER) + .and_then(|h| h.to_str().ok()) + { + let ref_host = referer_val + .split("//") + .nth(1) + .map(|s| s.split('/').next().unwrap_or(s)) + .unwrap_or(referer_val); + host = ref_host.to_string(); + } + + let slug2 = host + .split(':') + .next() + .unwrap_or(&host) + .split('.') + .next() + .unwrap_or(""); + + if slug2.is_empty() || slug2 == "localhost" || slug2 == "beecd" { + tracing::warn!( + "Invalid slug from host (after origin/referer fallback): {}", + host + ); + return Err(( + StatusCode::NOT_FOUND, + "No tenant subdomain found".to_string(), + )); + } + + // Use recovered slug + let result = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT id FROM tenants + WHERE domain = $1 AND status = 'active' AND deleted_at IS NULL + "#, + ) + .bind(slug2) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to lookup tenant by slug {}: {:?}", slug2, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to resolve tenant".to_string(), + ) + })?; + + return result.ok_or_else(|| { + tracing::warn!("Tenant not found for slug: {}", slug2); + ( + StatusCode::NOT_FOUND, + format!("Tenant not found for subdomain: {}", slug2), + ) + }); + } + + // Look up tenant by slug (domain column stores just the slug) + let result = sqlx::query_scalar::<_, Uuid>( + r#" + SELECT id FROM tenants + WHERE domain = $1 AND status = 'active' AND deleted_at IS NULL + "#, + ) + .bind(slug) + .fetch_optional(pool) + .await + .map_err(|e| { + tracing::error!("Failed to lookup tenant by slug {}: {:?}", slug, e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to resolve tenant".to_string(), + ) + })?; + + result.ok_or_else(|| { + tracing::warn!("Tenant not found for slug: {}", slug); + ( + StatusCode::NOT_FOUND, + format!("Tenant not found for subdomain: {}", slug), + ) + }) +} + +/// Set the RLS context for the current request +/// This sets app.tenant_id which is used by RLS policies +async fn set_tenant_context( + client: &mut sqlx::Transaction<'_, sqlx::Postgres>, + tenant_id: Uuid, +) -> Result<(), (StatusCode, String)> { + let query = format!("SET LOCAL app.tenant_id = '{}';", tenant_id); + sqlx::query(&query) + .execute(&mut **client) + .await + .map_err(|e| { + tracing::error!("Failed to set tenant context: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to set tenant context".to_string(), + ) + })?; + Ok(()) +} + +/// Get tenant domain from tenant_id for logging +async fn get_tenant_domain(pool: &sqlx::Pool, tenant_id: Uuid) -> String { + sqlx::query_scalar::<_, String>("SELECT domain FROM tenants WHERE id = $1") + .bind(tenant_id) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or_else(|| tenant_id.to_string()) +} + +/// Get a read-only transaction with tenant context set for RLS +/// This ensures all read queries respect tenant isolation +/// Returns (transaction, tenant_id, tenant_domain) for logging +async fn get_tenant_tx<'a>( + pool: &'a sqlx::Pool, + headers: &axum::http::HeaderMap, +) -> Result<(sqlx::Transaction<'a, sqlx::Postgres>, Uuid, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(pool, headers).await?; + let tenant_domain = get_tenant_domain(pool, tenant_id).await; + let mut tx = pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "get_tenant_tx_begin"))?; + set_tenant_context(&mut tx, tenant_id).await?; + Ok((tx, tenant_id, tenant_domain)) +} + /// Sanitize database errors to prevent information leakage /// Logs the full error server-side but returns generic message to client fn sanitize_db_error(e: sqlx::Error, context: &str) -> (StatusCode, String) { @@ -218,6 +396,64 @@ impl Pagination { } } +/// Crypto module for secret encryption/decryption +mod crypto { + use aes_gcm::aead::{Aead, KeyInit}; + use aes_gcm::{Aes256Gcm, Nonce}; + + const NONCE_SIZE: usize = 12; + + /// Encrypt plaintext using AES-256-GCM + pub fn encrypt(key: &[u8; 32], plaintext: &[u8]) -> Result<(Vec, Vec), String> { + if key.len() != 32 { + return Err("Key must be 32 bytes".to_string()); + } + + let cipher = Aes256Gcm::new(key.into()); + let mut nonce_bytes = [0u8; NONCE_SIZE]; + // In production, use a proper random source + getrandom::getrandom(&mut nonce_bytes) + .map_err(|e| format!("Failed to generate nonce: {}", e))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| format!("Encryption failed: {}", e))?; + + Ok((ciphertext, nonce_bytes.to_vec())) + } + + /// Decrypt ciphertext using AES-256-GCM + pub fn decrypt(key: &[u8; 32], ciphertext: &[u8], iv: &[u8]) -> Result, String> { + if key.len() != 32 { + return Err("Key must be 32 bytes".to_string()); + } + + if iv.len() != NONCE_SIZE { + return Err(format!("IV must be {} bytes", NONCE_SIZE)); + } + + let cipher = Aes256Gcm::new(key.into()); + let nonce = Nonce::from_slice(iv); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|e| format!("Decryption failed: {}", e)) + } + + /// Derive a key using HKDF + pub fn derive_key(root_key: &[u8], salt: &[u8]) -> Result<[u8; 32], String> { + use hkdf::Hkdf; + use sha2::Sha256; + + let hkdf = Hkdf::::new(Some(salt), root_key); + let mut key = [0u8; 32]; + hkdf.expand(b"secret-encryption", &mut key) + .map_err(|e| format!("HKDF expansion failed: {}", e))?; + Ok(key) + } +} + /// Get beecd-hive-hq version #[utoipa::path( get, @@ -298,6 +534,7 @@ pub async fn free_token( let token = generate_jwt( &state.jwt_secret_bytes, String::from("user@galleybytes.com"), + String::from("00000000-0000-0000-0000-000000000000"), // dev default tenant vec![String::from("admin")], ) .map_err(|e| { @@ -329,13 +566,15 @@ pub async fn free_token( )] pub async fn get_service( State(state): State, + headers: axum::http::HeaderMap, Path(name): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, ServiceDefinitionData>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + + let result = sqlx::query_as::<_, ServiceDefinitionData>( + r#" SELECT service_definitions.id AS service_definition_id, service_definitions.name AS name, @@ -359,14 +598,19 @@ pub async fn get_service( ORDER BY service_definitions.name LIMIT $2 OFFSET $3 "#, - ) - .bind(&name) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(&name) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_service"))?; + + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_service"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_service_commit"))?; + + Ok(Json(result)) } /// Get service data via id @@ -384,13 +628,15 @@ pub async fn get_service( )] pub async fn get_service_definition( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { // TODO in the ui, make use of the deleted_at timestamp to inform the // user that this resource, while visible, can not be used while it is deleted. - Ok(Json( - sqlx::query_as::<_, ServiceDefinitionData>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + + let result = sqlx::query_as::<_, ServiceDefinitionData>( + r#" SELECT service_definitions.id AS service_definition_id, service_definitions.name AS name, @@ -411,12 +657,17 @@ pub async fn get_service_definition( WHERE service_definitions.id = $1 "#, - ) - .bind(id) - .fetch_one(&state.readonly_pool) + ) + .bind(id) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_service_definition"))?; + + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_service_definition"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_service_definition_commit"))?; + + Ok(Json(result)) } #[derive(Serialize, Deserialize, ToSchema)] @@ -439,9 +690,20 @@ pub struct PutServiceData { )] pub async fn put_service_definition( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_service_definition_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -454,9 +716,14 @@ pub async fn put_service_definition( ) .bind(id) .bind(&data.source_branch_requirements) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_service_definition"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_service_definition_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -479,12 +746,14 @@ pub async fn put_service_definition( )] pub async fn get_unassociated_service_definitions_for_cluster_group( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, ServiceDefinitionData>( + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + + let result = sqlx::query_as::<_, ServiceDefinitionData>( r#" SELECT service_definitions.id AS service_definition_id, @@ -528,10 +797,15 @@ pub async fn get_unassociated_service_definitions_for_cluster_group( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await - .map_err(|e| sanitize_db_error(e, "get_put_unassociated_service_definitions"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_put_unassociated_service_definitions"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_unassociated_service_definitions_commit"))?; + + Ok(Json(result)) } /// Get a list of all non-deleted clusters @@ -553,14 +827,16 @@ pub async fn get_unassociated_service_definitions_for_cluster_group( )] pub async fn get_clusters( State(state): State, + headers: axum::http::HeaderMap, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; // Get total count let (total,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM clusters WHERE deleted_at IS NULL"#) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_clusters_count"))?; @@ -570,10 +846,14 @@ pub async fn get_clusters( ) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_clusters"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_clusters_commit"))?; + Ok(Json(PaginatedResponse::new( data, total, @@ -601,9 +881,12 @@ pub async fn get_clusters( )] pub async fn get_error_count( State(state): State, + headers: axum::http::HeaderMap, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as(r#" SELECT clusters.id as cluster_id, @@ -628,9 +911,14 @@ pub async fn get_error_count( "#) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_error_count"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_error_count_commit"))?; + Ok(Json(result)) } @@ -649,14 +937,21 @@ pub async fn get_error_count( )] pub async fn get_cluster( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as("SELECT * FROM clusters WHERE id = $1 AND deleted_at IS NULL") .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_commit"))?; + Ok(Json(result)) } @@ -675,8 +970,11 @@ pub async fn get_cluster( )] pub async fn delete_cluster( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + // Use transaction to ensure both operations succeed or fail together let mut tx = state .pool @@ -684,6 +982,8 @@ pub async fn delete_cluster( .await .map_err(|e| sanitize_db_error(e, "delete_cluster_begin_transaction"))?; + set_tenant_context(&mut tx, tenant_id).await?; + // When "soft" deleting a cluster, also delete the user that allows // the agent to register, effectively preventing any new queries to this cluster sqlx::query("DELETE FROM users WHERE name = (SELECT name FROM clusters WHERE id = $1)") @@ -729,6 +1029,7 @@ pub async fn delete_cluster( )] pub async fn post_cluster( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result, (StatusCode, String)> { // Validate cluster name @@ -740,12 +1041,23 @@ pub async fn post_cluster( )); } + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + let tenant_domain = get_tenant_domain(&state.pool, tenant_id).await; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_cluster_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Check if cluster already exists let existing_cluster: Option = sqlx::query_as( "SELECT id, name, metadata, version, kubernetes_version FROM clusters WHERE name = $1 AND deleted_at IS NULL" ) .bind(name) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_cluster_check_existing"))?; @@ -755,13 +1067,14 @@ pub async fn post_cluster( // Insert the cluster sqlx::query_as( r#" - INSERT INTO clusters (id, name) - VALUES (gen_random_uuid(), $1) + INSERT INTO clusters (id, name, tenant_id) + VALUES (gen_random_uuid(), $1, $2) RETURNING id, name, metadata, version, kubernetes_version "#, ) .bind(name) - .fetch_one(&state.pool) + .bind(tenant_id) + .fetch_one(&mut *tx) .await .map_err(|e| match e { sqlx::Error::Database(database_error) => { @@ -784,7 +1097,11 @@ pub async fn post_cluster( } } None => { - tracing::error!("Database error inserting cluster: {}", database_error); + tracing::error!( + "[tenant:{}] Database error inserting cluster: {}", + tenant_domain, + database_error + ); ( StatusCode::INTERNAL_SERVER_ERROR, String::from("Database error while creating cluster"), @@ -793,7 +1110,11 @@ pub async fn post_cluster( } } _ => { - tracing::error!("Unknown error inserting cluster: {}", e); + tracing::error!( + "[tenant:{}] Unknown error inserting cluster: {}", + tenant_domain, + e + ); ( StatusCode::INTERNAL_SERVER_ERROR, String::from("Failed to create cluster"), @@ -806,7 +1127,7 @@ pub async fn post_cluster( let existing_user: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM users WHERE name = $1 AND deleted_at IS NULL") .bind(name) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_cluster_check_user"))?; @@ -1123,24 +1444,29 @@ pub async fn post_cluster( sqlx::query("UPDATE users SET hash = $1, updated_at = NOW() WHERE name = $2") .bind(&hash) .bind(name) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_cluster_update_user"))?; } else { // Insert new user sqlx::query( r#" - INSERT INTO users (id, name, hash) - VALUES (gen_random_uuid(), $1, $2) + INSERT INTO users (id, name, hash, tenant_id) + VALUES (gen_random_uuid(), $1, $2, $3) "#, ) .bind(name) .bind(&hash) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_cluster_insert_user"))?; } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_cluster_commit"))?; + Ok(Json(types::PostClusterResponse { cluster, manifest: Some(manifest), @@ -1170,10 +1496,13 @@ pub async fn post_cluster( )] pub async fn get_cluster_namespaces( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as( r#" SELECT @@ -1197,10 +1526,14 @@ pub async fn get_cluster_namespaces( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_namespace_data"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_namespaces_commit"))?; + Ok(Json(result)) } @@ -1224,13 +1557,15 @@ pub async fn get_cluster_namespaces( )] pub async fn get_cluster_groups( State(state): State, + headers: axum::http::HeaderMap, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { let pagination = pagination.validate(); + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; // Get total count let (total,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM cluster_groups"#) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_groups_count"))?; @@ -1239,10 +1574,14 @@ pub async fn get_cluster_groups( sqlx::query_as(r#"SELECT * FROM cluster_groups ORDER BY name LIMIT $1 OFFSET $2"#) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_groups"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_groups_commit"))?; + Ok(Json(PaginatedResponse::new( data, total, @@ -1267,9 +1606,11 @@ pub async fn get_cluster_groups( )] pub async fn get_cluster_cluster_groups( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let result = sqlx::query_as( r#" @@ -1289,10 +1630,13 @@ pub async fn get_cluster_cluster_groups( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_cluster_groups"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_cluster_groups_commit"))?; Ok(Json(result)) } @@ -1312,8 +1656,19 @@ pub async fn get_cluster_cluster_groups( )] pub async fn delete_group_relationship( State(state): State, + headers: axum::http::HeaderMap, Path((cluster_id, cluster_group_id)): Path<(Uuid, Uuid)>, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_group_relationship_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" DELETE FROM @@ -1325,10 +1680,14 @@ pub async fn delete_group_relationship( ) .bind(cluster_id) .bind(cluster_group_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_group_relationship"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_group_relationship_commit"))?; + tokio::time::sleep(std::time::Duration::from_millis( state.read_replica_wait_in_ms, )) @@ -1564,9 +1923,11 @@ pub async fn sync_cluster_releases( )] pub async fn get_cluster_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let result = sqlx::query_as( r#" @@ -1618,10 +1979,13 @@ pub async fn get_cluster_service_definitions( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_service_definitions"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_service_definitions_commit"))?; Ok(Json(result)) } @@ -1641,6 +2005,7 @@ pub async fn get_cluster_service_definitions( )] pub async fn add_cluster_groups( State(state): State, + headers: axum::http::HeaderMap, Json(cluster_group): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { if cluster_group.name.is_empty() { @@ -1649,22 +2014,33 @@ pub async fn add_cluster_groups( "Null value for cluster group".to_string(), )); } + + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "add_cluster_groups_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" - INSERT INTO cluster_groups (id, name) - VALUES ( - ( - SELECT gen_random_uuid() - ), - $1 - ); + INSERT INTO cluster_groups (id, name, tenant_id) + VALUES (gen_random_uuid(), $1, $2); "#, ) .bind(cluster_group.name) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "add_cluster_groups"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "add_cluster_groups_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -1683,8 +2059,19 @@ pub async fn add_cluster_groups( )] pub async fn delete_cluster_group( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_cluster_group_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + #[derive(sqlx::FromRow)] struct Cluster { id: Uuid, @@ -1703,16 +2090,20 @@ pub async fn delete_cluster_group( "#, ) .bind(id) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_cluster_group_fetch_clusters"))?; sqlx::query("DELETE FROM cluster_groups WHERE id = $1") .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_cluster_group_delete"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_cluster_group_commit"))?; + tokio::time::sleep(std::time::Duration::from_millis( state.read_replica_wait_in_ms, )) @@ -1742,15 +2133,19 @@ pub async fn delete_cluster_group( )] pub async fn get_cluster_group( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { - Ok(Json( - sqlx::query_as("SELECT * FROM cluster_groups WHERE id = $1") - .bind(id) - .fetch_one(&state.readonly_pool) - .await - .map_err(|e| sanitize_db_error(e, "get_cluster_group"))?, - )) + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as("SELECT * FROM cluster_groups WHERE id = $1") + .bind(id) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_group"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_group_commit"))?; + Ok(Json(result)) } /// Update details of cluster group @@ -1768,9 +2163,20 @@ pub async fn get_cluster_group( )] pub async fn put_cluster_group( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_cluster_group_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -1785,7 +2191,7 @@ pub async fn put_cluster_group( .bind(id) .bind(&data.name) .bind(data.priority) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_cluster_group_update"))?; @@ -1807,14 +2213,13 @@ pub async fn put_cluster_group( "#, ) .bind(id) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_cluster_group_fetch_clusters"))?; - tokio::time::sleep(std::time::Duration::from_millis( - state.read_replica_wait_in_ms, - )) - .await; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_cluster_group_commit"))?; // Parallelize sync operations for all clusters let sync_futures = clusters @@ -1844,20 +2249,76 @@ pub async fn put_cluster_group( )] pub async fn get_resource_diffs_for_release( State(state): State, + headers: axum::http::HeaderMap, Path((release_id, diff_generation)): Path<(Uuid, i32)>, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - let diff_data_without_body = - list_resource_diffs(&state.readonly_pool, release_id, diff_generation) - .await - .map_err(|e| { - tracing::error!("get_resource_diffs_for_release error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to fetch resource diffs".to_string(), - ) - })?; + + // Query resource diffs within the tenant transaction + let diff_data_without_body: Vec = if diff_generation == -1 { + sqlx::query_as::<_, DiffData>( + r#" + SELECT + resource_diffs.key, + resource_diffs.release_id, + resource_diffs.diff_generation, + resource_diffs.change_order, + resource_diffs.storage_url + FROM + resource_diffs + WHERE + resource_diffs.release_id = $1 + AND resource_diffs.diff_generation = ( + SELECT diff_generation FROM releases WHERE id = $1 + ) + LIMIT 100 + "#, + ) + .bind(release_id) + .fetch_all(&mut *tx) + .await + .map_err(|e| { + tracing::error!("get_resource_diffs_for_release error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch resource diffs".to_string(), + ) + })? + } else { + sqlx::query_as::<_, DiffData>( + r#" + SELECT + resource_diffs.key, + resource_diffs.release_id, + resource_diffs.diff_generation, + resource_diffs.change_order, + resource_diffs.storage_url + FROM + resource_diffs + WHERE + resource_diffs.release_id = $1 + AND resource_diffs.diff_generation = $2 + LIMIT 100 + "#, + ) + .bind(release_id) + .bind(diff_generation) + .fetch_all(&mut *tx) + .await + .map_err(|e| { + tracing::error!("get_resource_diffs_for_release error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to fetch resource diffs".to_string(), + ) + })? + }; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_resource_diffs_for_release_commit"))?; // Apply pagination to results let paginated_diffs: Vec<_> = diff_data_without_body @@ -1925,8 +2386,19 @@ pub async fn get_resource_diffs_for_release( )] pub async fn put_release_selection( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_release_selection_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE releases @@ -1952,7 +2424,7 @@ pub async fn put_release_selection( "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_release_manual_selection_update1"))?; @@ -1971,7 +2443,7 @@ pub async fn put_release_selection( "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_release_manual_selection_update2"))?; @@ -1992,10 +2464,14 @@ pub async fn put_release_selection( "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_release_manual_selection_update3"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_release_selection_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -2016,8 +2492,19 @@ pub async fn put_release_selection( )] pub async fn put_select_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(service_version_id): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_select_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Get the service version details let sv = sqlx::query_as::<_, (Uuid, Uuid, String, String, String)>( r#" @@ -2032,7 +2519,7 @@ pub async fn put_select_service_version( "#, ) .bind(service_version_id) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))? .ok_or_else(|| { @@ -2049,7 +2536,7 @@ pub async fn put_select_service_version( r#"SELECT repo_branch_id, name FROM service_definitions WHERE id = $1"#, ) .bind(service_def_id) - .fetch_one(&state.pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))?; @@ -2062,7 +2549,7 @@ pub async fn put_select_service_version( ) .bind(namespace_id) .bind(service_version_id) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))?; @@ -2085,7 +2572,7 @@ pub async fn put_select_service_version( .bind(namespace_id) .bind(&name) .bind(existing_release_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))?; } else { @@ -2102,7 +2589,7 @@ pub async fn put_select_service_version( ) .bind(namespace_id) .bind(&name) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))?; @@ -2110,8 +2597,8 @@ pub async fn put_select_service_version( sqlx::query( r#" INSERT INTO releases - (id, service_id, namespace_id, name, version, git_sha, path, repo_branch_id, hash, manually_selected_at) - VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $4, NOW()) + (id, service_id, namespace_id, name, version, git_sha, path, repo_branch_id, hash, manually_selected_at, tenant_id) + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $4, NOW(), $8) "#, ) .bind(service_version_id) @@ -2121,11 +2608,16 @@ pub async fn put_select_service_version( .bind(&git_sha) .bind(&path) .bind(repo_branch_id) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_select_service_version"))?; } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_select_service_version_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -2148,8 +2640,19 @@ pub async fn put_select_service_version( )] pub async fn put_restore_latest_release( State(state): State, + headers: axum::http::HeaderMap, Path((id, release_name)): Path<(Uuid, String)>, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_restore_latest_release_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + match sqlx::query( r#" UPDATE releases @@ -2164,7 +2667,7 @@ pub async fn put_restore_latest_release( ) .bind(id) .bind(&release_name) - .execute(&state.pool) + .execute(&mut *tx) .await { Ok(pg_query_result) => { @@ -2198,7 +2701,7 @@ pub async fn put_restore_latest_release( ) .bind(id) .bind(&release_name) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await { Ok(row) => row.path, @@ -2229,7 +2732,7 @@ pub async fn put_restore_latest_release( ) .bind(id) .bind(&release_name) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await{ Ok(row) => row.repo_branch_id, Err(e) => { return Err(sanitize_db_error(e, "put_restore_latest_release_find_repo_branch"));} @@ -2247,7 +2750,8 @@ pub async fn put_restore_latest_release( repo_branch_id, version, git_sha, - hash + hash, + tenant_id ) VALUES ( @@ -2259,17 +2763,23 @@ pub async fn put_restore_latest_release( $4, '-', '', - '' + '', + $5 )"#, ) .bind(id) .bind(&path) .bind(&release_name) .bind(repo_branch_id) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_restore_latest_release_insert"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_restore_latest_release_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -2292,9 +2802,11 @@ pub async fn put_restore_latest_release( )] pub async fn get_release_status( State(state): State, + headers: axum::http::HeaderMap, Path(cluster_id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let result = sqlx::query_as::<_, ReleaseData>( r#" @@ -2345,7 +2857,7 @@ pub async fn get_release_status( .bind(cluster_id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_releases_via_id"))? .into_iter() @@ -2357,6 +2869,9 @@ pub async fn get_release_status( }) .collect::>(); + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_release_status_commit"))?; Ok(Json(result)) } @@ -2379,9 +2894,11 @@ pub async fn get_release_status( )] pub async fn get_namespace_releases( State(state): State, + headers: axum::http::HeaderMap, Path((id, release_name)): Path<(Uuid, String)>, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let mut releases = sqlx::query_as::<_, ReleaseData>( r#" @@ -2439,10 +2956,14 @@ pub async fn get_namespace_releases( .bind(&release_name) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_namespace_releases"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_namespace_releases_commit"))?; + // Compute paths from templates for release in &mut releases { compute_release_path(release); @@ -2474,10 +2995,12 @@ pub async fn get_namespace_releases( )] pub async fn get_release_service_versions( State(state): State, + headers: axum::http::HeaderMap, Path((namespace_id, release_name)): Path<(Uuid, String)>, Query(pagination): Query, Query(params): Query>, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let deployed_only = params .get("deployed_only") @@ -2496,7 +3019,7 @@ pub async fn get_release_service_versions( ) .bind(namespace_id) .bind(&release_name) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_release_service_versions"))? .ok_or_else(|| (StatusCode::NOT_FOUND, "Release not found".to_string()))?; @@ -2511,7 +3034,7 @@ pub async fn get_release_service_versions( ) .bind(namespace_id) .bind(&release_name) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_release_service_versions"))? .flatten(); @@ -2542,7 +3065,7 @@ pub async fn get_release_service_versions( let total = sqlx::query_scalar::<_, i64>(&count_str) .bind(service_def_id) .bind(namespace_id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_release_service_versions"))?; @@ -2585,10 +3108,14 @@ pub async fn get_release_service_versions( .bind(pagination.limit) .bind(current_service_id) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_release_service_versions"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_release_service_versions_commit"))?; + Ok(Json(PaginatedResponse::new( versions, total, @@ -2612,8 +3139,10 @@ pub async fn get_release_service_versions( )] pub async fn get_namespace_release_info( State(state): State, + headers: axum::http::HeaderMap, Path((id, release_name)): Path<(Uuid, String)>, ) -> Result, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let mut result = sqlx::query_as::<_, ReleaseData>( r#" SELECT @@ -2655,10 +3184,14 @@ pub async fn get_namespace_release_info( ) .bind(id) .bind(&release_name) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_namespace_release_info"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_namespace_release_info_commit"))?; + // Compute path from template compute_release_path(&mut result); @@ -2685,11 +3218,12 @@ pub async fn get_namespace_release_info( )] pub async fn get_hive_agent_errors( State(state): State, + headers: axum::http::HeaderMap, Path(cluster_id): Path, ) -> Result>, (StatusCode, String)> { - Ok(Json( - sqlx::query_as::<_, HiveError>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as::<_, HiveError>( + r#" SELECT message, updated_at @@ -2700,12 +3234,15 @@ pub async fn get_hive_agent_errors( AND deprecated_at IS NULL ORDER BY updated_at DESC LIMIT 10 "#, - ) - .bind(cluster_id) - .fetch_all(&state.readonly_pool) + ) + .bind(cluster_id) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_hive_agent_errors"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_hive_agent_errors"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_hive_agent_errors_commit"))?; + Ok(Json(result)) } /// Gets the last heartbeat information for an agent given the cluster id @@ -2723,11 +3260,12 @@ pub async fn get_hive_agent_errors( )] pub async fn get_hive_agent_heartbeat( State(state): State, + headers: axum::http::HeaderMap, Path(cluster_id): Path, ) -> Result, (StatusCode, String)> { - Ok(Json( - sqlx::query_as::<_, Heartbeat>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as::<_, Heartbeat>( + r#" SELECT last_check_in_at, deleted_at @@ -2736,12 +3274,15 @@ pub async fn get_hive_agent_heartbeat( WHERE id = $1 "#, - ) - .bind(cluster_id) - .fetch_one(&state.readonly_pool) + ) + .bind(cluster_id) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_heartbeat"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_heartbeat"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_hive_agent_heartbeat_commit"))?; + Ok(Json(result)) } /// Gets a list of pending all pending releases @@ -2759,10 +3300,11 @@ pub async fn get_hive_agent_heartbeat( )] pub async fn get_pending_releases( State(state): State, + headers: axum::http::HeaderMap, ) -> Result>, (StatusCode, String)> { - Ok(Json( - sqlx::query_as::<_, PendingReleases>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as::<_, PendingReleases>( + r#" SELECT clusters.id AS cluster_id, STRING_AGG(releases.name, ', ') AS release_names, @@ -2777,11 +3319,14 @@ pub async fn get_pending_releases( WHERE clusters.deleted_at IS NULL GROUP BY clusters.id "#, - ) - .fetch_all(&state.readonly_pool) + ) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_pending_releases_without_pagination"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_pending_releases_without_pagination"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_pending_releases_commit"))?; + Ok(Json(result)) } /// Gets a list of the latest errors produced by a specific release @@ -2803,13 +3348,14 @@ pub async fn get_pending_releases( )] pub async fn get_release_errors( State(state): State, + headers: axum::http::HeaderMap, Path(cluster_id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, HiveError>( - r#" + let result = sqlx::query_as::<_, HiveError>( + r#" SELECT message, updated_at @@ -2821,14 +3367,17 @@ pub async fn get_release_errors( ORDER BY updated_at DESC LIMIT $2 OFFSET $3 "#, - ) - .bind(cluster_id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(cluster_id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_release_errors"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_release_errors"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_release_errors_commit"))?; + Ok(Json(result)) } /// Update a list of releases for approval @@ -2847,6 +3396,7 @@ pub async fn get_release_errors( )] pub async fn put_approvals( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result>, (StatusCode, String)> { if is_empty_or_has_empty_string(&data.ids) { @@ -2855,6 +3405,16 @@ pub async fn put_approvals( "No releases subbmited for approval".to_string(), )) } else { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_approvals_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE releases @@ -2867,7 +3427,7 @@ pub async fn put_approvals( "#, ) .bind(&data.ids) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_approvals_update"))?; @@ -2885,6 +3445,10 @@ pub async fn put_approvals( }) .collect::, _>>()?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_approvals_commit"))?; + // Batch fetch all release candidates at once instead of N queries let all_release_candidates = list_mass_approval_release_candidates_batch(&state.readonly_pool, release_ids) @@ -2917,6 +3481,7 @@ pub async fn put_approvals( )] pub async fn put_unapprovals( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { if is_empty_or_has_empty_string(&data.ids) { @@ -2925,6 +3490,16 @@ pub async fn put_unapprovals( "No releases subbmited for unapproval".to_string(), )) } else { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_unapprovals_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE releases @@ -2938,10 +3513,14 @@ pub async fn put_unapprovals( "#, ) .bind(&data.ids) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_unapprovals"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_unapprovals_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } } @@ -2965,9 +3544,11 @@ pub async fn put_unapprovals( )] pub async fn get_cluster_group_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let result = sqlx::query_as( r#" @@ -3001,10 +3582,13 @@ pub async fn get_cluster_group_service_definitions( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_cluster_group_service_definitions"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_group_service_definitions_commit"))?; Ok(Json(result)) } @@ -3024,22 +3608,28 @@ pub async fn get_cluster_group_service_definitions( )] pub async fn delete_service_definition_relationship( State(state): State, + headers: axum::http::HeaderMap, Path((cluster_group_id, service_definition_id)): Path<(Uuid, Uuid)>, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_definition_relationship_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( "DELETE FROM service_definition_cluster_group_relationships WHERE cluster_group_id = $1 AND service_definition_id = $2", ) .bind(cluster_group_id) .bind(service_definition_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_service_definition_relationship"))?; - tokio::time::sleep(std::time::Duration::from_millis( - state.read_replica_wait_in_ms, - )) - .await; - #[derive(sqlx::FromRow)] struct Cluster { id: Uuid, @@ -3058,10 +3648,14 @@ pub async fn delete_service_definition_relationship( "#, ) .bind(cluster_group_id) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_fetch_clusters"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_definition_relationship_commit"))?; + // Parallelize sync operations for all clusters let sync_futures = clusters .into_iter() @@ -3087,8 +3681,19 @@ pub async fn delete_service_definition_relationship( )] pub async fn delete_service_from_namespace( State(state): State, + headers: axum::http::HeaderMap, Path((namespace_id, service_name)): Path<(String, String)>, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_from_namespace_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -3103,9 +3708,14 @@ pub async fn delete_service_from_namespace( ) .bind(namespace_id) .bind(service_name) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_service_from_namespace"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_from_namespace_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -3128,13 +3738,14 @@ pub async fn delete_service_from_namespace( )] pub async fn get_cluster_group_cluster_association( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as( - r#" + let result = sqlx::query_as( + r#" SELECT clusters.*, CASE @@ -3160,26 +3771,30 @@ pub async fn get_cluster_group_cluster_association( ORDER BY clusters.name LIMIT $2 OFFSET $3 "#, - ) - .bind(id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_cluster_group_cluster_association"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_cluster_group_cluster_association"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_cluster_group_cluster_association_commit"))?; + Ok(Json(result)) } fn generate_jwt( jwt_secret_bytes: &[u8], email: String, + tenant_id: String, roles: Vec, ) -> Result> { // Token expiration set to 2 hours for security // NOTE: Database has refresh_tokens table (see migrations/20241216000001_add_refresh_tokens.sql) // TODO: Implement refresh token flow to allow token renewal without re-authentication let expiration = match SystemTime::now().duration_since(UNIX_EPOCH) { - Ok(now) => now + Duration::from_secs(2 * 60 * 60), // 2 hours (reduced from 12 for security) + Ok(now) => now + Duration::from_secs(2 * 60 * 60), // 2 hours Err(e) => { let message = format!("Error generating JWT expiration date: {:?}", e); error!("{}", message); @@ -3189,6 +3804,7 @@ fn generate_jwt( let claims = Claim { email, + tenant_id, exp: expiration.as_secs() as usize, roles, }; @@ -3286,6 +3902,15 @@ async fn validate_auth_with_roles( .map_err(|_| StatusCode::UNAUTHORIZED)? .ok_or(StatusCode::UNAUTHORIZED)?; + // Enforce that the session tenant matches the domain being used + let request_tenant_id = extract_tenant_from_request(&state.pool, req.headers()) + .await + .map_err(|_| StatusCode::UNAUTHORIZED)?; + let user_tenant_id = user.tenant_id.ok_or(StatusCode::UNAUTHORIZED)?; + if user_tenant_id != request_tenant_id { + return Err(StatusCode::UNAUTHORIZED); + } + if let Some(required) = required_roles { let has_required_role = user .roles @@ -3330,6 +3955,10 @@ pub struct UiAuthMeResponse { pub id: Uuid, pub username: String, pub roles: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_name: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -3337,11 +3966,54 @@ pub struct UiAuthBootstrapStatusResponse { pub bootstrap_required: bool, } -fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { - let raw = headers.get(axum::http::header::COOKIE)?; - let s = std::str::from_utf8(raw.as_bytes()).ok()?; - for part in s.split(';') { - let part = part.trim(); +#[derive(Debug, Deserialize, ToSchema)] +pub struct TenantRegisterRequest { + #[serde(alias = "email")] + pub username: String, + pub password: String, + pub tenant_name: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TenantRegisterResponse { + pub tenant_id: Uuid, + pub domain: String, + pub user_id: Uuid, + pub email: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct TenantData { + pub id: Uuid, + pub domain: String, + pub name: String, + pub status: String, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateSecretRequest { + pub purpose: String, + pub plaintext: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SecretMetadata { + pub id: Uuid, + pub purpose: String, + pub created_at: String, + pub key_version: i16, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct SecretListResponse { + pub secrets: Vec, +} + +fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { + let raw = headers.get(axum::http::header::COOKIE)?; + let s = std::str::from_utf8(raw.as_bytes()).ok()?; + for part in s.split(';') { + let part = part.trim(); if part.is_empty() { continue; } @@ -3356,18 +4028,28 @@ fn cookie_value(headers: &axum::http::HeaderMap, name: &str) -> Option { None } -fn build_session_cookie_value(token: &str, max_age_seconds: i64, secure: bool) -> String { +fn build_session_cookie_value( + token: &str, + max_age_seconds: i64, + secure: bool, + domain: Option<&str>, +) -> String { // SameSite Strict since you said same-site and no CORS. // Secure is optional for local http dev. + let domain_attr = domain + .filter(|d| !d.is_empty()) + .map(|d| format!("; Domain={}", d)) + .unwrap_or_default(); + if secure { format!( - "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}; Secure", - UI_SESSION_COOKIE_NAME, token, max_age_seconds + "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}; Secure{}", + UI_SESSION_COOKIE_NAME, token, max_age_seconds, domain_attr ) } else { format!( - "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", - UI_SESSION_COOKIE_NAME, token, max_age_seconds + "{}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}{}", + UI_SESSION_COOKIE_NAME, token, max_age_seconds, domain_attr ) } } @@ -3411,43 +4093,25 @@ async fn lookup_ui_session( session_token: &str, ) -> Result, sqlx::Error> { let token_hash = util::hash_string(session_token); - let row = sqlx::query_as::<_, (Uuid, String, Vec)>( + + // Use SECURITY DEFINER function to bypass RLS for session lookup + let row = sqlx::query_as::<_, (Uuid, String, Vec, Option, Option)>( r#" - SELECT - u.id, - u.username, - u.roles - FROM - ui_sessions s - JOIN ui_users u ON u.id = s.user_id - WHERE - s.token_hash = $1 - AND s.revoked_at IS NULL - AND s.expires_at > NOW() - AND u.deleted_at IS NULL + SELECT user_id, username, roles, tenant_id, tenant_name + FROM auth_lookup_ui_session($1) "#, ) - .bind(token_hash) + .bind(&token_hash) .fetch_optional(pool) .await?; - if let Some((id, username, roles)) = row { - // Best-effort touch. Don't fail auth if this update fails. - let _ = sqlx::query( - r#" - UPDATE ui_sessions - SET last_seen_at = NOW() - WHERE token_hash = $1 - "#, - ) - .bind(util::hash_string(session_token)) - .execute(pool) - .await; - + if let Some((id, username, roles, tenant_id, tenant_name)) = row { Ok(Some(UiAuthMeResponse { id, username, roles, + tenant_id, + tenant_name, })) } else { Ok(None) @@ -3515,17 +4179,53 @@ pub async fn ui_auth_bootstrap( let roles: Vec = vec!["admin".to_string()]; + // Start a transaction for creating tenant, user, and session + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_begin"))?; + + // Create a bootstrap tenant (domain based on username or default) + let tenant_domain = format!( + "bootstrap-{}", + username + .split('@') + .next() + .unwrap_or("admin") + .chars() + .filter(|c| c.is_alphanumeric() || *c == '-') + .take(20) + .collect::() + ); + let tenant_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tenants (id, domain, name, status, config) + VALUES (gen_random_uuid(), $1, 'Bootstrap Tenant', 'active', '{}') + RETURNING id + "#, + ) + .bind(&tenant_domain) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_tenant"))?; + + // Set tenant context for RLS + set_tenant_context(&mut tx, tenant_id).await?; + + // Create the first admin user let created = sqlx::query_as::<_, (Uuid,)>( r#" - INSERT INTO ui_users (id, username, password_hash, roles) - VALUES (gen_random_uuid(), $1, $2, $3) + INSERT INTO ui_users (id, tenant_id, username, password_hash, roles) + VALUES (gen_random_uuid(), $1, $2, $3, $4) RETURNING id "#, ) + .bind(tenant_id) .bind(username) .bind(password_hash) .bind(&roles) - .fetch_one(&state.pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_insert"))?; @@ -3539,20 +4239,21 @@ pub async fn ui_auth_bootstrap( })?; let session_hash = util::hash_string(&session_token); - sqlx::query( - r#" - INSERT INTO ui_sessions (id, user_id, token_hash, expires_at) - VALUES (gen_random_uuid(), $1, $2, $3) - "#, - ) - .bind(created.0) - .bind(session_hash) - .bind(expires_at) - .execute(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_session_insert"))?; + // Use SECURITY DEFINER function to bypass RLS (no tenant context yet) + sqlx::query("SELECT auth_create_ui_session($1, $2, $3, $4)") + .bind(&session_hash) + .bind(created.0) + .bind(tenant_id) + .bind(expires_at) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_session_insert"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_bootstrap_commit"))?; - let cookie = build_session_cookie_value(&session_token, ttl_seconds, ui_cookie_secure()); + let cookie = build_session_cookie_value(&session_token, ttl_seconds, ui_cookie_secure(), None); let header = ( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).map_err(|_| { @@ -3570,6 +4271,8 @@ pub async fn ui_auth_bootstrap( id: created.0, username: username.to_string(), roles, + tenant_id: None, + tenant_name: None, }), )) } @@ -3614,6 +4317,7 @@ pub async fn ui_auth_bootstrap_status( )] pub async fn ui_auth_login( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result< ( @@ -3632,25 +4336,51 @@ pub async fn ui_auth_login( )); } - let row = sqlx::query_as::<_, (Uuid, String, String, Vec)>( + // Extract tenant from request first + // This ensures we look up the user within the correct tenant + let request_tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + // Use transaction with tenant context for RLS + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_login_begin"))?; + set_tenant_context(&mut tx, request_tenant_id).await?; + + // Query user by username AND tenant_id + // This prevents returning the wrong user when same username exists in multiple tenants + let row = sqlx::query_as::<_, (Uuid, String, String, Vec, Uuid)>( r#" - SELECT id, username, password_hash, roles + SELECT id, username, password_hash, roles, tenant_id FROM ui_users - WHERE deleted_at IS NULL AND username = $1 + WHERE deleted_at IS NULL AND username = $1 AND tenant_id = $2 "#, ) .bind(username) - .fetch_optional(&state.pool) + .bind(request_tenant_id) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "ui_auth_login_select"))?; - let Some((user_id, username_db, password_hash, roles)) = row else { + let Some((user_id, username_db, password_hash, roles, tenant_id)) = row else { return Err(( StatusCode::UNAUTHORIZED, String::from("Invalid username or password"), )); }; + // Fetch tenant name for response (tenants table has permissive RLS) + let tenant_name: Option = sqlx::query_scalar( + r#" + SELECT name FROM tenants WHERE id = $1 + "#, + ) + .bind(tenant_id) + .fetch_optional(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_login_tenant_select"))?; + let ok = bcrypt::verify(password.trim(), &password_hash).unwrap_or(false); if !ok { return Err(( @@ -3669,20 +4399,21 @@ pub async fn ui_auth_login( })?; let session_hash = util::hash_string(&session_token); - sqlx::query( - r#" - INSERT INTO ui_sessions (id, user_id, token_hash, expires_at) - VALUES (gen_random_uuid(), $1, $2, $3) - "#, - ) - .bind(user_id) - .bind(session_hash) - .bind(expires_at) - .execute(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "ui_auth_login_session_insert"))?; + // Use SECURITY DEFINER function to bypass RLS (no tenant context yet) + sqlx::query("SELECT auth_create_ui_session($1, $2, $3, $4)") + .bind(&session_hash) + .bind(user_id) + .bind(tenant_id) + .bind(expires_at) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_login_session_insert"))?; - let cookie = build_session_cookie_value(&session_token, ttl_seconds, ui_cookie_secure()); + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "ui_auth_login_commit"))?; + + let cookie = build_session_cookie_value(&session_token, ttl_seconds, ui_cookie_secure(), None); let header = ( axum::http::header::SET_COOKIE, axum::http::HeaderValue::from_str(&cookie).map_err(|_| { @@ -3700,6 +4431,8 @@ pub async fn ui_auth_login( id: user_id, username: username_db, roles, + tenant_id: Some(tenant_id), + tenant_name, }), )) } @@ -3728,16 +4461,11 @@ pub async fn ui_auth_logout( > { if let Some(token) = cookie_value(req.headers(), UI_SESSION_COOKIE_NAME) { let token_hash = util::hash_string(&token); - let _ = sqlx::query( - r#" - UPDATE ui_sessions - SET revoked_at = NOW() - WHERE token_hash = $1 AND revoked_at IS NULL - "#, - ) - .bind(token_hash) - .execute(&state.pool) - .await; + // Use SECURITY DEFINER function to bypass RLS + let _ = sqlx::query("SELECT auth_revoke_ui_session($1)") + .bind(&token_hash) + .execute(&state.pool) + .await; } let cookie = build_clear_session_cookie_value(ui_cookie_secure()); @@ -3781,6 +4509,713 @@ pub async fn ui_auth_me( Ok(Json(user)) } +/// Generate a domain slug from tenant name. +/// Rules: +/// - Only lowercase ASCII letters (a-z), digits (0-9), and hyphens (-) allowed +/// - No leading or trailing hyphens +/// - No consecutive hyphens +/// - Non-alphanumeric characters become hyphens (collapsed) +/// - Maximum 63 characters (DNS subdomain limit) +/// - Empty result if no valid characters +fn generate_domain_slug(name: &str) -> String { + let mut result = String::new(); + let mut last_was_hyphen = true; // Treat start as hyphen to prevent leading hyphen + + for c in name.chars() { + if result.len() >= 63 { + break; + } + + if c.is_ascii_lowercase() || c.is_ascii_digit() { + result.push(c); + last_was_hyphen = false; + } else if c.is_ascii_uppercase() { + result.push(c.to_ascii_lowercase()); + last_was_hyphen = false; + } else if !last_was_hyphen { + // Any non-alphanumeric character becomes a hyphen + result.push('-'); + last_was_hyphen = true; + } + // Skip consecutive non-alphanumeric characters + } + + // Remove trailing hyphen if present + while result.ends_with('-') { + result.pop(); + } + + result +} + +#[cfg(test)] +mod slug_tests { + use super::generate_domain_slug; + + #[test] + fn test_simple_lowercase() { + assert_eq!(generate_domain_slug("acme"), "acme"); + } + + #[test] + fn test_uppercase_to_lowercase() { + assert_eq!(generate_domain_slug("ACME"), "acme"); + assert_eq!(generate_domain_slug("AcMe"), "acme"); + } + + #[test] + fn test_spaces_become_hyphens() { + assert_eq!(generate_domain_slug("acme corp"), "acme-corp"); + assert_eq!(generate_domain_slug("my company name"), "my-company-name"); + } + + #[test] + fn test_underscores_become_hyphens() { + assert_eq!(generate_domain_slug("acme_corp"), "acme-corp"); + } + + #[test] + fn test_no_double_hyphens() { + assert_eq!(generate_domain_slug("acme--corp"), "acme-corp"); + assert_eq!(generate_domain_slug("acme---corp"), "acme-corp"); + assert_eq!(generate_domain_slug("acme - corp"), "acme-corp"); + assert_eq!(generate_domain_slug("acme corp"), "acme-corp"); + } + + #[test] + fn test_no_leading_hyphen() { + assert_eq!(generate_domain_slug("-acme"), "acme"); + assert_eq!(generate_domain_slug("--acme"), "acme"); + assert_eq!(generate_domain_slug(" acme"), "acme"); + } + + #[test] + fn test_no_trailing_hyphen() { + assert_eq!(generate_domain_slug("acme-"), "acme"); + assert_eq!(generate_domain_slug("acme--"), "acme"); + assert_eq!(generate_domain_slug("acme "), "acme"); + } + + #[test] + fn test_numbers_allowed() { + assert_eq!(generate_domain_slug("acme123"), "acme123"); + assert_eq!(generate_domain_slug("123acme"), "123acme"); + assert_eq!(generate_domain_slug("acme-123-corp"), "acme-123-corp"); + } + + #[test] + fn test_special_chars_become_hyphens() { + assert_eq!(generate_domain_slug("acme!@#$%corp"), "acme-corp"); + assert_eq!(generate_domain_slug("acme.corp"), "acme-corp"); + assert_eq!(generate_domain_slug("acme&corp"), "acme-corp"); + } + + #[test] + fn test_unicode_becomes_hyphens() { + assert_eq!(generate_domain_slug("acme\u{00e9}corp"), "acme-corp"); // e with accent + assert_eq!(generate_domain_slug("acme\u{4e2d}corp"), "acme-corp"); // Chinese char + assert_eq!(generate_domain_slug("\u{00fc}ber"), "ber"); // u with umlaut at start + } + + #[test] + fn test_empty_input() { + assert_eq!(generate_domain_slug(""), ""); + } + + #[test] + fn test_only_invalid_chars() { + assert_eq!(generate_domain_slug("!@#$%^"), ""); + assert_eq!(generate_domain_slug("---"), ""); + assert_eq!(generate_domain_slug(" "), ""); + } + + #[test] + fn test_mixed_valid_invalid() { + assert_eq!(generate_domain_slug(" --acme-- "), "acme"); + assert_eq!(generate_domain_slug("!!!acme!!!"), "acme"); + } + + #[test] + fn test_realistic_company_names() { + assert_eq!(generate_domain_slug("Acme Corporation"), "acme-corporation"); + assert_eq!(generate_domain_slug("Smith & Sons Ltd."), "smith-sons-ltd"); + assert_eq!(generate_domain_slug("O'Reilly Media"), "o-reilly-media"); + assert_eq!(generate_domain_slug("AT&T"), "at-t"); + assert_eq!(generate_domain_slug("3M Company"), "3m-company"); + } + + #[test] + fn test_max_length_63_chars() { + // Exactly 63 chars should be preserved + let input_63 = "a".repeat(63); + assert_eq!(generate_domain_slug(&input_63).len(), 63); + + // 64+ chars should be truncated to 63 + let input_100 = "a".repeat(100); + assert_eq!(generate_domain_slug(&input_100).len(), 63); + + // Long name with spaces should truncate correctly + let long_name = + "this is an extremely long company name that exceeds the dns subdomain limit"; + let slug = generate_domain_slug(long_name); + assert!(slug.len() <= 63); + assert!(!slug.ends_with('-')); + assert!(!slug.starts_with('-')); + } + + #[test] + fn test_truncation_removes_trailing_hyphen() { + // If truncation lands on a hyphen, it should be removed + // "abcdefghij" repeated 6 times = 60 chars, then " xy" would add "-xy" making 63 + // but if we have 62 chars + space, truncation at 63 would leave trailing hyphen + let input = "a".repeat(62) + " xyz"; + let slug = generate_domain_slug(&input); + assert!(slug.len() <= 63); + assert!(!slug.ends_with('-')); + } +} + +/// Register a new tenant and create first admin user +#[utoipa::path( + post, + path = "/api/tenants/register", + request_body = TenantRegisterRequest, + responses( + (status = 201, description = "Tenant and user created successfully", body = TenantRegisterResponse), + (status = 400, description = "Invalid request data"), + (status = 409, description = "Email or domain already exists"), + (status = 500, description = "Database or server error"), + ) +)] +pub async fn register_tenant( + State(state): State, + headers: axum::http::HeaderMap, + Json(data): Json, +) -> Result< + ( + StatusCode, + [(axum::http::HeaderName, axum::http::HeaderValue); 1], + Json, + ), + (StatusCode, String), +> { + let email = data.username.trim().to_lowercase(); + let password = data.password.trim(); + let tenant_name = data.tenant_name.trim(); + + // Validation + if email.is_empty() || !email.contains('@') { + return Err((StatusCode::BAD_REQUEST, "Invalid email".to_string())); + } + if password.len() < 8 { + return Err(( + StatusCode::BAD_REQUEST, + "Password must be at least 8 characters".to_string(), + )); + } + if tenant_name.is_empty() || tenant_name.len() > 255 { + return Err(( + StatusCode::BAD_REQUEST, + "Tenant name must be between 1 and 255 characters".to_string(), + )); + } + + // Generate domain slug from tenant name + let domain_slug = generate_domain_slug(tenant_name); + if domain_slug.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "Tenant name must contain alphanumeric characters".to_string(), + )); + } + + // Store just the slug in domain column (e.g., 'acme' not 'acme.beecd.local') + // This prevents collisions and works with any host variant + let domain = domain_slug.clone(); + + let password_hash = util::bcrypt_string(password).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to hash password".to_string(), + ) + })?; + + // Start transaction: create tenant, then user + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "register_tenant_begin_tx"))?; + + // Create tenant + let tenant_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO tenants (id, domain, name, status, config) + VALUES (gen_random_uuid(), $1, $2, 'active', '{}') + ON CONFLICT (domain) DO NOTHING + RETURNING id + "#, + ) + .bind(&domain) + .bind(tenant_name) + .fetch_optional(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "register_tenant_insert_tenant"))? + .ok_or_else(|| (StatusCode::CONFLICT, "Domain already exists".to_string()))?; + + tracing::info!("Created tenant with id: {}", tenant_id); + + // Set tenant context for RLS - required before inserting tenant-scoped data + set_tenant_context(&mut tx, tenant_id).await?; + tracing::info!("Set tenant context for RLS"); + + // Create user in the new tenant + let user_id: Uuid = sqlx::query_scalar( + r#" + INSERT INTO ui_users (id, tenant_id, username, password_hash, roles) + VALUES (gen_random_uuid(), $1, $2, $3, ARRAY['admin']::text[]) + RETURNING id + "#, + ) + .bind(tenant_id) + .bind(&email) + .bind(&password_hash) + .fetch_one(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to insert user: {:?}", e); + // Check for unique constraint violation + if let sqlx::Error::Database(ref db_err) = e { + if db_err.is_unique_violation() { + return ( + StatusCode::CONFLICT, + "Email already registered in this tenant".to_string(), + ); + } + } + sanitize_db_error(e, "register_tenant_insert_user") + })?; + + tracing::info!("Created user with id: {}", user_id); + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "register_tenant_commit_tx"))?; + + // Create session for the new user + let ttl_seconds = ui_session_ttl_seconds(); + let expires_at = Utc::now() + chrono::Duration::seconds(ttl_seconds); + let session_token = util::generate_secure_token_256().map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to generate session token".to_string(), + ) + })?; + let session_hash = util::hash_string(&session_token); + + // Use SECURITY DEFINER function to bypass RLS (tenant just created) + sqlx::query("SELECT auth_create_ui_session($1, $2, $3, $4)") + .bind(&session_hash) + .bind(user_id) + .bind(tenant_id) + .bind(expires_at) + .execute(&state.pool) + .await + .map_err(|e| sanitize_db_error(e, "register_tenant_session_insert"))?; + + // Share cookie across subdomains so redirect lands authenticated + // Use the Host header to extract the parent domain (not the stored domain which may differ .local vs .localhost) + let host = headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or("localhost"); + let request_domain = host.split(':').next().unwrap_or(host); + let cookie_domain = request_domain + .split_once('.') + .map(|(_, rest)| format!(".{}", rest)) + .unwrap_or_default(); + + let cookie = build_session_cookie_value( + &session_token, + ttl_seconds, + ui_cookie_secure(), + if cookie_domain.is_empty() { + None + } else { + Some(&cookie_domain) + }, + ); + let header = ( + axum::http::header::SET_COOKIE, + axum::http::HeaderValue::from_str(&cookie).map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to set session cookie".to_string(), + ) + })?, + ); + + Ok(( + StatusCode::CREATED, + [header], + Json(TenantRegisterResponse { + tenant_id, + domain, + user_id, + email, + }), + )) +} + +/// Store a secret (GitHub token, PGP key, etc.) encrypted in the database +#[utoipa::path( + post, + path = "/api/secrets", + security( + ("bearerAuth"=[]), + ), + request_body = CreateSecretRequest, + responses( + (status = 201, description = "Secret created successfully"), + (status = 400, description = "Invalid request"), + (status = 401, description = "Not authenticated"), + (status = 409, description = "Secret purpose already exists for this tenant"), + (status = 500, description = "Database or encryption error"), + ) +)] +pub async fn create_secret( + State(state): State, + headers: axum::http::HeaderMap, + Json(data): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + // Get current user and tenant + let Some(token) = headers + .get(axum::http::header::COOKIE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| { + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(format!("{}=", UI_SESSION_COOKIE_NAME).as_str()) + { + return Some(v.to_string()); + } + } + None + }) + else { + return Err((StatusCode::UNAUTHORIZED, "Not authenticated".to_string())); + }; + + let _user = lookup_ui_session(&state.pool, &token) + .await + .map_err(|e| sanitize_db_error(e, "create_secret_lookup_user"))? + .ok_or_else(|| (StatusCode::UNAUTHORIZED, "Not authenticated".to_string()))?; + + // Get bootstrap key from environment + let bootstrap_key_str = std::env::var("HIVE_CRYPTO_ROOT_KEY").map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Bootstrap key not configured".to_string(), + ) + })?; + + let bootstrap_key_bytes = base64::engine::general_purpose::STANDARD + .decode(&bootstrap_key_str) + .map_err(|_| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid bootstrap key format".to_string(), + ) + })?; + + if bootstrap_key_bytes.len() != 32 { + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "Bootstrap key must be 32 bytes".to_string(), + )); + } + + let mut bootstrap_key = [0u8; 32]; + bootstrap_key.copy_from_slice(&bootstrap_key_bytes); + + // Validate inputs + if data.purpose.is_empty() || data.plaintext.is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + "purpose and plaintext are required".to_string(), + )); + } + + // Resolve tenant from Host header + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + // Encrypt the secret + let (ciphertext, iv) = + crypto::encrypt(&bootstrap_key, data.plaintext.as_bytes()).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Encryption failed: {}", e), + ) + })?; + + // Persist to database with upsert + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "create_secret_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + + let row = sqlx::query_as::<_, (Uuid, chrono::DateTime, i16)>( + r#" + INSERT INTO tenant_secrets (tenant_id, purpose, ciphertext, iv, key_version) + VALUES ($1, $2, $3, $4, 1) + ON CONFLICT (tenant_id, purpose) WHERE deleted_at IS NULL + DO UPDATE SET + ciphertext = EXCLUDED.ciphertext, + iv = EXCLUDED.iv, + updated_at = NOW() + RETURNING id, created_at, key_version + "#, + ) + .bind(tenant_id) + .bind(&data.purpose) + .bind(&ciphertext) + .bind(&iv) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "create_secret_upsert"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "create_secret_commit"))?; + + Ok(( + StatusCode::CREATED, + Json(SecretMetadata { + id: row.0, + purpose: data.purpose, + created_at: row.1.to_rfc3339(), + key_version: row.2, + }), + )) +} + +/// List secrets (metadata only, no plaintext decryption) +#[utoipa::path( + get, + path = "/api/secrets", + security( + ("bearerAuth"=[]), + ), + responses( + (status = 200, description = "List of secrets", body = SecretListResponse), + (status = 401, description = "Not authenticated"), + (status = 500, description = "Database error"), + ) +)] +pub async fn list_secrets( + State(state): State, + headers: axum::http::HeaderMap, +) -> Result, (StatusCode, String)> { + let Some(_token) = headers + .get(axum::http::header::COOKIE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| { + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(format!("{}=", UI_SESSION_COOKIE_NAME).as_str()) + { + return Some(v.to_string()); + } + } + None + }) + else { + return Err((StatusCode::UNAUTHORIZED, "Not authenticated".to_string())); + }; + + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "list_secrets_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + + let rows = sqlx::query_as::<_, (Uuid, String, chrono::DateTime, i16)>( + r#" + SELECT id, purpose, created_at, key_version + FROM tenant_secrets + WHERE tenant_id = $1 AND deleted_at IS NULL + ORDER BY created_at DESC + "#, + ) + .bind(tenant_id) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "list_secrets_query"))?; + + let secrets: Vec = rows + .into_iter() + .map(|(id, purpose, created_at, key_version)| SecretMetadata { + id, + purpose, + created_at: created_at.to_rfc3339(), + key_version, + }) + .collect(); + + Ok(Json(SecretListResponse { secrets })) +} + +/// Delete a secret (soft delete) +#[utoipa::path( + delete, + path = "/api/secrets/{purpose}", + security( + ("bearerAuth"=[]), + ), + responses( + (status = 204, description = "Secret deleted"), + (status = 401, description = "Not authenticated"), + (status = 404, description = "Secret not found"), + (status = 500, description = "Database error"), + ) +)] +pub async fn delete_secret( + State(state): State, + headers: axum::http::HeaderMap, + axum::extract::Path(purpose): axum::extract::Path, +) -> Result { + let Some(_token) = headers + .get(axum::http::header::COOKIE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| { + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(format!("{}=", UI_SESSION_COOKIE_NAME).as_str()) + { + return Some(v.to_string()); + } + } + None + }) + else { + return Err((StatusCode::UNAUTHORIZED, "Not authenticated".to_string())); + }; + + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_secret_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + + let result = sqlx::query( + r#" + UPDATE tenant_secrets + SET deleted_at = NOW(), updated_at = NOW() + WHERE tenant_id = $1 AND purpose = $2 AND deleted_at IS NULL + "#, + ) + .bind(tenant_id) + .bind(&purpose) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "delete_secret_update"))?; + + if result.rows_affected() == 0 { + return Err((StatusCode::NOT_FOUND, "Secret not found".to_string())); + } + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_secret_commit"))?; + + Ok(StatusCode::NO_CONTENT) +} + +/// Retrieve a secret's encrypted ciphertext for agent-side decryption +/// This endpoint supports the optional Phase 5 agent-side secret passing pattern +/// The agent can optionally decrypt secrets locally using its own key material +#[utoipa::path( + get, + path = "/api/secrets/{purpose}/encrypted", + security( + ("bearerAuth"=[]), + ), + responses( + (status = 200, description = "Encrypted secret data"), + (status = 401, description = "Not authenticated"), + (status = 404, description = "Secret not found"), + (status = 500, description = "Database error"), + ) +)] +pub async fn get_encrypted_secret( + State(state): State, + headers: axum::http::HeaderMap, + axum::extract::Path(purpose): axum::extract::Path, +) -> Result, (StatusCode, String)> { + let Some(_token) = headers + .get(axum::http::header::COOKIE) + .and_then(|h| h.to_str().ok()) + .and_then(|s| { + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(format!("{}=", UI_SESSION_COOKIE_NAME).as_str()) + { + return Some(v.to_string()); + } + } + None + }) + else { + return Err((StatusCode::UNAUTHORIZED, "Not authenticated".to_string())); + }; + + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "get_encrypted_secret_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + + let row = sqlx::query_as::<_, (Vec, Vec, i16)>( + r#" + SELECT ciphertext, iv, key_version + FROM tenant_secrets + WHERE tenant_id = $1 AND purpose = $2 AND deleted_at IS NULL + "#, + ) + .bind(tenant_id) + .bind(&purpose) + .fetch_optional(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_encrypted_secret_query"))?; + + let (ciphertext, iv, key_version) = + row.ok_or_else(|| (StatusCode::NOT_FOUND, "Secret not found".to_string()))?; + + // Return encrypted data as base64 for safe transport + let result = json!({ + "ciphertext": base64::engine::general_purpose::STANDARD.encode(&ciphertext), + "iv": base64::engine::general_purpose::STANDARD.encode(&iv), + "key_version": key_version, + }); + + Ok(Json(result)) +} + /// Add a new namespace to a cluster via id #[utoipa::path( post, @@ -3797,6 +5232,7 @@ pub async fn ui_auth_me( )] pub async fn post_create_cluster_namespaces( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -3806,18 +5242,34 @@ pub async fn post_create_cluster_namespaces( "Null value in namespace entry".to_string(), )) } else { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_create_cluster_namespaces_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" - INSERT INTO namespaces (id, cluster_id, name) - SELECT gen_random_uuid(), $1, unnest($2::text[]) - ON CONFLICT DO NOTHING + INSERT INTO namespaces (id, cluster_id, name, tenant_id) + SELECT gen_random_uuid(), $1, unnest($2::text[]), $3 + ON CONFLICT (tenant_id, cluster_id, name) DO NOTHING "#, ) .bind(id) .bind(data.namespace_names) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_namespace"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_create_cluster_namespaces_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } } @@ -3838,9 +5290,20 @@ pub async fn post_create_cluster_namespaces( )] pub async fn post_subscribe_clusters( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Validation make sure that no 'service @ repo' of proposed cluster is a dedup of service @ different repo #[derive(sqlx::FromRow)] struct ClusterGroup { @@ -3853,7 +5316,7 @@ pub async fn post_subscribe_clusters( "#, ) .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_fetch_group"))?; @@ -3902,7 +5365,7 @@ pub async fn post_subscribe_clusters( .bind(id) .bind(cluster_id) .bind(priority) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_validate"))?; @@ -3922,29 +5385,25 @@ pub async fn post_subscribe_clusters( } } - let list = data - .ids - .iter() - .map(|s| format!("('{}', '{}')", id, s)) - .collect::>(); - sqlx::query(&format!( - " - INSERT INTO group_relationships - (cluster_group_id, cluster_id) - VALUES - {} - ON CONFLICT DO NOTHING - ", - list.join(",") - )) - .execute(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_insert"))?; + for cluster_id in data.ids.iter() { + sqlx::query( + r#" + INSERT INTO group_relationships (tenant_id, cluster_group_id, cluster_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(cluster_id) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_insert"))?; + } - tokio::time::sleep(std::time::Duration::from_millis( - state.read_replica_wait_in_ms, - )) - .await; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_clusters_commit"))?; // Parallelize sync operations for all clusters let sync_futures = data @@ -3972,9 +5431,20 @@ pub async fn post_subscribe_clusters( )] pub async fn post_subscribe_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + #[derive(sqlx::FromRow)] struct ClusterGroup { priority: Option, @@ -3986,7 +5456,7 @@ pub async fn post_subscribe_service_definitions( "#, ) .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_fetch_group"))?; @@ -4030,7 +5500,7 @@ pub async fn post_subscribe_service_definitions( .bind(id) .bind(service_definition_id) .bind(priority) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_validate"))?; @@ -4048,7 +5518,7 @@ pub async fn post_subscribe_service_definitions( "#, ) .bind(service_definition_id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map(|r| r.name) .unwrap_or_else(|_| service_definition_id.to_string()); @@ -4064,28 +5534,21 @@ pub async fn post_subscribe_service_definitions( } } - let list = data - .ids - .into_iter() - .map(|s| format!("('{}', '{}')", id, s)) - .collect::>(); - sqlx::query(&format!( - " - INSERT INTO service_definition_cluster_group_relationships - (cluster_group_id, service_definition_id) - VALUES - {} - ", - list.join(",") - )) - .execute(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_insert"))?; - - tokio::time::sleep(std::time::Duration::from_millis( - state.read_replica_wait_in_ms, - )) - .await; + for service_definition_id in data.ids.iter() { + sqlx::query( + r#" + INSERT INTO service_definition_cluster_group_relationships (tenant_id, cluster_group_id, service_definition_id) + VALUES ($1, $2, $3) + ON CONFLICT DO NOTHING + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(service_definition_id) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_insert"))?; + } #[derive(sqlx::FromRow)] struct Cluster { @@ -4105,10 +5568,14 @@ pub async fn post_subscribe_service_definitions( "#, ) .bind(id) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_fetch_clusters"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_subscribe_service_definitions_commit"))?; + // Parallelize sync operations for all clusters let sync_futures = clusters .into_iter() @@ -4134,9 +5601,20 @@ pub async fn post_subscribe_service_definitions( )] pub async fn put_subscribe_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Validation make sure that no registered clusters of this group have the same service already associated via another group #[derive(sqlx::FromRow)] struct ClusterGroup { @@ -4149,7 +5627,7 @@ pub async fn put_subscribe_service_definitions( "#, ) .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_fetch_group"))?; @@ -4193,7 +5671,7 @@ pub async fn put_subscribe_service_definitions( .bind(id) .bind(service_definition_id) .bind(priority) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_validate"))?; @@ -4233,7 +5711,7 @@ pub async fn put_subscribe_service_definitions( ) .bind(id) .bind(service_definition_id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_fetch_old"))?; @@ -4242,33 +5720,25 @@ pub async fn put_subscribe_service_definitions( ) .bind(id) .bind(old_service_definition.id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_delete"))?; } - let list = data - .ids - .into_iter() - .map(|s| format!("('{}', '{}')", id, s)) - .collect::>(); - sqlx::query(&format!( - " - INSERT INTO service_definition_cluster_group_relationships - (cluster_group_id, service_definition_id) - VALUES - {} - ", - list.join(",") - )) - .execute(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "post_global_repo_service_insert"))?; - - tokio::time::sleep(std::time::Duration::from_millis( - state.read_replica_wait_in_ms, - )) - .await; + for service_definition_id in data.ids.iter() { + sqlx::query( + r#" + INSERT INTO service_definition_cluster_group_relationships (tenant_id, cluster_group_id, service_definition_id) + VALUES ($1, $2, $3) + "#, + ) + .bind(tenant_id) + .bind(id) + .bind(service_definition_id) + .execute(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_insert"))?; + } #[derive(sqlx::FromRow)] struct Cluster { @@ -4288,10 +5758,14 @@ pub async fn put_subscribe_service_definitions( "#, ) .bind(id) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_fetch_clusters"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_subscribe_service_definitions_commit"))?; + // Parallelize sync operations for all clusters let sync_futures = clusters .into_iter() @@ -4320,14 +5794,16 @@ pub async fn put_subscribe_service_definitions( )] pub async fn get_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); // Get total count let (total,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM service_definitions WHERE deleted_at IS NULL"#) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_service_definitions_count"))?; @@ -4361,10 +5837,14 @@ pub async fn get_service_definitions( ) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_service_definitions"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_service_definitions_commit"))?; + Ok(Json(PaginatedResponse::new( data, total, @@ -4392,9 +5872,11 @@ pub async fn get_service_definitions( )] pub async fn get_service_releases( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let releases = sqlx::query_as::<_, ReleaseData>( r#" @@ -4446,10 +5928,14 @@ pub async fn get_service_releases( .bind(id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_service_releases"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_service_releases_commit"))?; + Ok(Json( releases .into_iter() @@ -4477,8 +5963,11 @@ pub async fn get_service_releases( )] pub async fn post_repo( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result, (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + let parsed = parse_repo_url(&data.url).ok_or(( StatusCode::UNPROCESSABLE_ENTITY, "Invalid repo URL. Expected like https://// or git@:/.git" @@ -4514,11 +6003,19 @@ pub async fn post_repo( let web_base_url = data.web_base_url.clone().unwrap_or(parsed.web_base_url); let api_base_url = data.api_base_url.clone().unwrap_or(parsed.api_base_url); + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_repo_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let ci_upsert = sqlx::query_as::<_, RepoData>( r#" - INSERT INTO repos (id, org, repo, provider, host, web_base_url, api_base_url) - VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6) - ON CONFLICT ON CONSTRAINT unique_repo_identity_ci + INSERT INTO repos (id, org, repo, provider, host, web_base_url, api_base_url, tenant_id) + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7) + ON CONFLICT ON CONSTRAINT unique_repo_identity_per_tenant DO UPDATE SET provider = EXCLUDED.provider, web_base_url = EXCLUDED.web_base_url, @@ -4531,45 +6028,17 @@ pub async fn post_repo( .bind(&provider) .bind(&parsed.host) .bind(&web_base_url) - .bind(&api_base_url); - - let repo = match ci_upsert.fetch_one(&state.pool).await { - Ok(repo) => repo, - Err(sqlx::Error::Database(db_err)) => { - // If the case-insensitive constraint isn't present (or migration is mid-flight), - // fall back to the older constraint so repo creation still works. - let pg_code = db_err.code().map(|c| c.to_string()); - if matches!(pg_code.as_deref(), Some("42704") | Some("42P10")) { - sqlx::query_as::<_, RepoData>( - r#" - INSERT INTO repos (id, org, repo, provider, host, web_base_url, api_base_url) - VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6) - ON CONFLICT ON CONSTRAINT unique_repo_identity - DO UPDATE SET - provider = EXCLUDED.provider, - web_base_url = EXCLUDED.web_base_url, - api_base_url = EXCLUDED.api_base_url - RETURNING id, provider, host, web_base_url, api_base_url, org, repo - "#, - ) - .bind(&parsed.org) - .bind(&parsed.repo) - .bind(&provider) - .bind(&parsed.host) - .bind(&web_base_url) - .bind(&api_base_url) - .fetch_one(&state.pool) - .await - .map_err(|e| sanitize_db_error(e, "post_repo_upsert_legacy"))? - } else { - return Err(sanitize_db_error( - sqlx::Error::Database(db_err), - "post_repo_upsert", - )); - } - } - Err(e) => return Err(sanitize_db_error(e, "post_repo_upsert")), - }; + .bind(&api_base_url) + .bind(tenant_id); + + let repo = ci_upsert + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "post_repo_upsert"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_repo_commit"))?; Ok(Json(repo)) } @@ -4593,13 +6062,15 @@ pub async fn post_repo( )] pub async fn get_repos( State(state): State, + headers: axum::http::HeaderMap, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); // Get total count let (total,): (i64,) = sqlx::query_as(r#"SELECT COUNT(*) FROM repos"#) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_repos_count"))?; @@ -4609,10 +6080,14 @@ pub async fn get_repos( ) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_repos"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_repos_commit"))?; + Ok(Json(PaginatedResponse::new( data, total, @@ -4636,19 +6111,23 @@ pub async fn get_repos( )] pub async fn get_repo( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { - Ok(Json( - sqlx::query_as::<_, RepoData>( - r#" + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as::<_, RepoData>( + r#" SELECT id, provider, host, web_base_url, api_base_url, org, repo FROM repos WHERE id = $1 "#, - ) - .bind(id) - .fetch_one(&state.readonly_pool) + ) + .bind(id) + .fetch_one(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_repo"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_repo"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_repo_commit"))?; + Ok(Json(result)) } /// Gets branches for a specific repo via id @@ -4670,13 +6149,14 @@ pub async fn get_repo( )] pub async fn get_branches( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, RepoBranches>( - r#" + let result = sqlx::query_as::<_, RepoBranches>( + r#" SELECT repo_branches.id as id, repos.provider as provider, @@ -4696,14 +6176,17 @@ pub async fn get_branches( ORDER BY branch LIMIT $2 OFFSET $3 "#, - ) - .bind(id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_branches"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_branches"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_branches_commit"))?; + Ok(Json(result)) } /// Add a new branch to a specific repo via id @@ -4722,6 +6205,7 @@ pub async fn get_branches( )] pub async fn post_branch( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -4733,22 +6217,27 @@ pub async fn post_branch( )); } + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + let mut tx = state .pool .begin() .await .map_err(|e| sanitize_db_error(e, "post_branch_begin"))?; + set_tenant_context(&mut tx, tenant_id).await?; + // Create the branch and capture its ID so we can copy existing repo services onto it. let new_branch_id: Uuid = sqlx::query_scalar( r#" - INSERT INTO repo_branches (id, repo_id, branch) - VALUES (gen_random_uuid(), $1, $2) + INSERT INTO repo_branches (id, repo_id, branch, tenant_id) + VALUES (gen_random_uuid(), $1, $2, $3) RETURNING id "#, ) .bind(id) .bind(branch) + .bind(tenant_id) .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_branch_insert"))?; @@ -4783,7 +6272,8 @@ pub async fn post_branch( repo_branch_id, name, source_branch_requirements, - manifest_path_template + manifest_path_template, + tenant_id ) SELECT gen_random_uuid(), @@ -4796,14 +6286,16 @@ pub async fn post_branch( CASE WHEN es.manifest_template_distinct_count = 1 AND es.manifest_template_single IS NOT NULL THEN es.manifest_template_single ELSE '{cluster}/manifests/{namespace}/' || es.name || '/' || es.name || '.yaml' - END + END, + $3 FROM existing_services es - ON CONFLICT (repo_branch_id, name) DO NOTHING + ON CONFLICT (tenant_id, repo_branch_id, name) DO NOTHING "#, ) .bind(new_branch_id) .bind(id) + .bind(tenant_id) .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_branch_copy_union_services"))?; @@ -4834,13 +6326,14 @@ pub async fn post_branch( )] pub async fn get_branch_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, ServiceDefinitionData>( - r#" + let result = sqlx::query_as::<_, ServiceDefinitionData>( + r#" SELECT service_definitions.id AS service_definition_id, service_definitions.deleted_at AS service_deleted_at, @@ -4869,14 +6362,17 @@ pub async fn get_branch_service_definitions( ORDER BY service_definitions.name LIMIT $2 OFFSET $3 "#, - ) - .bind(id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_branch_service_definitions"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_branch_service_definitions"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_branch_service_definitions_commit"))?; + Ok(Json(result)) } /// Given a branch id, get a list other branches with "sync" configuration data @@ -4898,13 +6394,14 @@ pub async fn get_branch_service_definitions( )] pub async fn get_autosync_data( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, AutosyncData>( - r#" + let result = sqlx::query_as::<_, AutosyncData>( + r#" SELECT id, branch, @@ -4940,14 +6437,17 @@ pub async fn get_autosync_data( ORDER BY branch LIMIT $2 OFFSET $3; "#, - ) - .bind(id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_branch_service_definitions_fetch"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_branch_service_definitions_fetch"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_autosync_data_commit"))?; + Ok(Json(result)) } /// Update a branch to by synced with other branches @@ -4965,9 +6465,20 @@ pub async fn get_autosync_data( )] pub async fn put_branch_autosync( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "put_branch_autosync_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -4980,7 +6491,7 @@ pub async fn put_branch_autosync( ) .bind(id) .bind(&data.ids) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_branch_autosync_update"))?; @@ -5004,23 +6515,29 @@ pub async fn put_branch_autosync( ) ) INSERT INTO - service_definitions (id, repo_branch_id, name, deleted_at, manifest_path_template) + service_definitions (id, repo_branch_id, name, deleted_at, manifest_path_template, tenant_id) SELECT gen_random_uuid(), $1, service_names.name, -- Instead of preventing sync, add the resource as deleted service_names.deleted_at, - service_names.manifest_path_template + service_names.manifest_path_template, + $2 FROM service_names ON CONFLICT DO NOTHING "#, ) .bind(id) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "put_branch_autosync_insert"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "put_branch_autosync_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } @@ -5039,6 +6556,7 @@ pub async fn put_branch_autosync( )] pub async fn post_branch_service( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -5068,6 +6586,16 @@ pub async fn post_branch_service( // ``` // // For now, just re-enable the service that is synced with this branch + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_branch_service_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" WITH matching_repo_branches AS ( @@ -5080,15 +6608,16 @@ pub async fn post_branch_service( OR $1 = ANY(service_autosync) ) INSERT INTO - service_definitions (id, repo_branch_id, name, manifest_path_template) + service_definitions (id, repo_branch_id, name, manifest_path_template, tenant_id) SELECT gen_random_uuid(), matching_repo_branches.id, $2, - $3 + $3, + $4 FROM matching_repo_branches - ON CONFLICT (repo_branch_id, name) DO UPDATE SET + ON CONFLICT (tenant_id, repo_branch_id, name) DO UPDATE SET deleted_at = NULL "#, @@ -5099,9 +6628,14 @@ pub async fn post_branch_service( "{{cluster}}/manifests/{{namespace}}/{}/{}.yaml", &data.name, &data.name )) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_branch_service"))?; + + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_branch_service_commit"))?; Ok((StatusCode::NO_CONTENT, String::new())) } @@ -5124,13 +6658,14 @@ pub async fn post_branch_service( )] pub async fn get_repo_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, ServiceName>( - r#" + let result = sqlx::query_as::<_, ServiceName>( + r#" SELECT service_definitions.name, MIN(service_definitions.manifest_path_template) AS manifest_path_template @@ -5161,14 +6696,17 @@ pub async fn get_repo_service_definitions( ORDER BY service_definitions.name LIMIT $2 OFFSET $3; "#, - ) - .bind(id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_repo_service_definitions"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_repo_service_definitions"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_repo_service_definitions_commit"))?; + Ok(Json(result)) } #[derive(Serialize, Deserialize, sqlx::FromRow, ToSchema)] @@ -5193,17 +6731,22 @@ pub struct NamespaceData { )] pub async fn get_namespaces_via_cluster_name( State(state): State, + headers: axum::http::HeaderMap, Path(cluster_name): Path, ) -> Result, (StatusCode, String)> { - let result = sqlx::query_as::<_, NamespaceData>(r#"SELECT id, name FROM namespaces WHERE cluster_id = (SELECT id FROM clusters WHERE clusters.name = $1 AND clusters.deleted_at IS NULL)"#) + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; + let result = sqlx::query_as::<_, NamespaceData>(r#"SELECT id, name FROM namespaces WHERE cluster_id = (SELECT id FROM clusters WHERE clusters.name = $1 AND clusters.deleted_at IS NULL)"#) .bind(&cluster_name) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_namespaces_via_cluster_name"))? .into_iter() .map(|r| (r.name, r.id.to_string())) .collect::>(); + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_namespaces_via_cluster_name_commit"))?; Ok(Json(json!(result))) } @@ -5223,6 +6766,7 @@ pub async fn get_namespaces_via_cluster_name( )] pub async fn post_global_repo_service( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result<(StatusCode, String), (StatusCode, String)> { @@ -5252,30 +6796,46 @@ pub async fn post_global_repo_service( // ``` // // For now, just re-enable the service + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_global_repo_service_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" INSERT INTO - service_definitions (id, repo_branch_id, name, manifest_path_template) + service_definitions (id, repo_branch_id, name, manifest_path_template, tenant_id) SELECT GEN_RANDOM_UUID(), id, $2, - $3 + $3, + $4 FROM repo_branches WHERE repo_id = $1 - ON CONFLICT (repo_branch_id, name) DO UPDATE SET + ON CONFLICT (tenant_id, repo_branch_id, name) DO UPDATE SET deleted_at = NULL "#, ) .bind(id) .bind(&data.name) .bind(&template) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_branch_service_upsert"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_global_repo_service_commit"))?; + Ok((StatusCode::NO_CONTENT, String::new())) } } @@ -5284,7 +6844,14 @@ async fn insert_new_releases_to_namespace( db: &sqlx::Pool, namespace_id: &Uuid, service_definitions: &[ServiceDefinitionInfo], + tenant_id: Uuid, ) -> Result<(), Box> { + let mut tx = db.begin().await?; + + // Set tenant context for RLS + let query = format!("SET LOCAL app.tenant_id = '{}';", tenant_id); + sqlx::query(&query).execute(&mut *tx).await?; + let cluster_data = sqlx::query_as::<_, ClusterNamespaceServicesData>( r#" SELECT @@ -5305,7 +6872,7 @@ async fn insert_new_releases_to_namespace( "#, ) .bind(namespace_id) - .fetch_one(db) + .fetch_one(&mut *tx) .await?; let cluster_name = cluster_data.name; @@ -5316,6 +6883,7 @@ async fn insert_new_releases_to_namespace( INSERT INTO releases ( id, + tenant_id, service_id, namespace_id, path, @@ -5346,6 +6914,7 @@ async fn insert_new_releases_to_namespace( let new_item = format!( r#"( (SELECT GEN_RANDOM_UUID()), + '{}', (SELECT GEN_RANDOM_UUID()), '{}', '{}', @@ -5355,7 +6924,7 @@ async fn insert_new_releases_to_namespace( '', '' )"#, - namespace_id, path, item.name, item.repo_branch_id + tenant_id, namespace_id, path, item.name, item.repo_branch_id ); if index == 0 { @@ -5365,7 +6934,8 @@ async fn insert_new_releases_to_namespace( } } - sqlx::query(&query).execute(db).await?; + sqlx::query(&query).execute(&mut *tx).await?; + tx.commit().await?; Ok(()) } @@ -5373,7 +6943,14 @@ async fn get_new_service_definitions_to_namespace( db: &sqlx::Pool, namespace_id: &Uuid, service_definition_ids: &Vec, + tenant_id: Uuid, ) -> Result, Box> { + let mut tx = db.begin().await?; + + // Set tenant context for RLS + let query = format!("SET LOCAL app.tenant_id = '{}';", tenant_id); + sqlx::query(&query).execute(&mut *tx).await?; + let existing_releases = sqlx::query_as::<_, NamespaceServiceData>( r#" SELECT @@ -5390,14 +6967,14 @@ async fn get_new_service_definitions_to_namespace( ;"#, ) .bind(namespace_id) - .fetch_all(db) + .fetch_all(&mut *tx) .await?; let service_info = sqlx::query_as::<_, ServiceDefinitionInfo>( "SELECT id, name, repo_branch_id, manifest_path_template FROM service_definitions WHERE id = ANY($1) AND service_definitions.deleted_at IS NULL", ) .bind(service_definition_ids) - .fetch_all(db) + .fetch_all(&mut *tx) .await?; let existing_service_names = existing_releases @@ -5434,24 +7011,31 @@ struct ServiceDefinitionInfo { )] pub async fn post_init_release( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result>, (StatusCode, String)> { - let new_service_definitions = - get_new_service_definitions_to_namespace(&state.pool, &id, &data.service_definition_ids) - .await - .map_err(|e| { - tracing::error!("Failed finding service_definitions new to namespace: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - "Database error occurred".to_string(), - ) - })?; + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let new_service_definitions = get_new_service_definitions_to_namespace( + &state.pool, + &id, + &data.service_definition_ids, + tenant_id, + ) + .await + .map_err(|e| { + tracing::error!("Failed finding service_definitions new to namespace: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error occurred".to_string(), + ) + })?; let additional_installations = if new_service_definitions.is_empty() { vec![] } else { - insert_new_releases_to_namespace(&state.pool, &id, &new_service_definitions) + insert_new_releases_to_namespace(&state.pool, &id, &new_service_definitions, tenant_id) .await .map_err(|e| { tracing::error!("Failed to insert new service release to namespace: {}", e); @@ -5469,12 +7053,31 @@ pub async fn post_init_release( exists: bool, } - let new_service_definitions_stream = stream::iter(new_service_definitions); + // Create a transaction with tenant context for the additional installations query + let mut tx = state.pool.begin().await.map_err(|e| { + tracing::error!("Failed to begin transaction: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error occurred".to_string(), + ) + })?; + + let ctx_query = format!("SET LOCAL app.tenant_id = '{}';", tenant_id); + sqlx::query(&ctx_query) + .execute(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to set tenant context: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Database error occurred".to_string(), + ) + })?; + + let mut service_definitions_new_to_cluster_namespaces = vec![]; - let db_pool = &state.pool.clone(); - let service_definitions_new_to_cluster_namespaces = new_service_definitions_stream - .fold(vec![], |mut a, service_definition| async move { - let clusters_with_same_namespace_in_group_with_service_relationship = match sqlx::query_as::<_, ClusterInGroupWithServiceDefinition>( + for service_definition in new_service_definitions.iter() { + let clusters_with_same_namespace_in_group_with_service_relationship = sqlx::query_as::<_, ClusterInGroupWithServiceDefinition>( r#" SELECT clusters_in_group_with_common_namespace.namespace_id AS namespace_id, @@ -5567,30 +7170,25 @@ pub async fn post_init_release( ) .bind(id) .bind(service_definition.id) - .fetch_all(db_pool) - .await{ - Ok(v) => v, - Err(e) => { - error!("Failed to find additional installation canidates: {}", e); - vec![] - }, - }; + .fetch_all(&mut *tx) + .await + .map_err(|e| { + tracing::error!("Failed to find additional installation candidates: {}", e); + (StatusCode::INTERNAL_SERVER_ERROR, "Database error occurred".to_string()) + })?; - clusters_with_same_namespace_in_group_with_service_relationship - .iter() - .filter(|item| !item.exists) - .for_each(|item| { - a.push(AdditionalInstallation{ - namespace_id: item.namespace_id, - namespace_name: item.namespace_name.clone(), - service_definition_id: service_definition.id, - cluster_name: item.cluster_name.clone(), - service_name: service_definition.name.clone(), - }) + for item in clusters_with_same_namespace_in_group_with_service_relationship.iter() { + if !item.exists { + service_definitions_new_to_cluster_namespaces.push(AdditionalInstallation { + namespace_id: item.namespace_id, + namespace_name: item.namespace_name.clone(), + service_definition_id: service_definition.id, + cluster_name: item.cluster_name.clone(), + service_name: service_definition.name.clone(), }); - a - }) - .await; + } + } + } service_definitions_new_to_cluster_namespaces }; @@ -5617,6 +7215,7 @@ pub async fn post_init_release( )] pub async fn post_additional_installations( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json>, ) -> Result<(StatusCode, String), (StatusCode, String)> { if data.is_empty() { @@ -5626,6 +7225,8 @@ pub async fn post_additional_installations( )); } + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + let installation_map = data.iter() .fold::>, _>(HashMap::new(), |mut acc, item| { @@ -5643,6 +7244,7 @@ pub async fn post_additional_installations( &state.pool, namespace_id, service_definition_id, + tenant_id, ) .await .map_err(|e| { @@ -5653,15 +7255,20 @@ pub async fn post_additional_installations( ) })?; - insert_new_releases_to_namespace(&state.pool, namespace_id, &service_definitions) - .await - .map_err(|e| { - tracing::error!("Failed to insert additional releases: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - String::from("Failed to insert releases for namespace"), - ) - })?; + insert_new_releases_to_namespace( + &state.pool, + namespace_id, + &service_definitions, + tenant_id, + ) + .await + .map_err(|e| { + tracing::error!("Failed to insert additional releases: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + String::from("Failed to insert releases for namespace"), + ) + })?; } Ok((StatusCode::NO_CONTENT, String::new())) @@ -5685,8 +7292,11 @@ pub async fn post_additional_installations( )] pub async fn post_user( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result, (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + let secret = util::generate_random_string(256); let manifest = match data.context { @@ -5760,17 +7370,26 @@ pub async fn post_user( String::from("Failed to create secure hash for user"), ) })?; + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_user_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" INSERT INTO users - (id, name, hash) + (id, name, hash, tenant_id) VALUES - ((SELECT gen_random_uuid()), $1, $2) + ((SELECT gen_random_uuid()), $1, $2, $3) "#, ) .bind(&data.name) .bind(&hash) - .execute(&state.pool) + .bind(tenant_id) + .execute(&mut *tx) .await .map_err(|e| match e { sqlx::Error::Database(database_error) => { @@ -5808,6 +7427,10 @@ pub async fn post_user( } })?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_user_commit"))?; + Ok(Json(UserData { secret, manifest })) } @@ -5862,8 +7485,19 @@ async fn clean_up_service_relationships( )] pub async fn delete_service_definitions( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_definitions_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -5876,10 +7510,14 @@ pub async fn delete_service_definitions( "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_definitions_commit"))?; + clean_up_service_relationships(&state.pool).await?; Ok((StatusCode::NO_CONTENT, String::new())) @@ -5900,8 +7538,19 @@ pub async fn delete_service_definitions( )] pub async fn delete_service( State(state): State, + headers: axum::http::HeaderMap, Path(name): Path, ) -> Result<(StatusCode, String), (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + sqlx::query( r#" UPDATE @@ -5914,10 +7563,14 @@ pub async fn delete_service( "#, ) .bind(&name) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_commit"))?; + clean_up_service_relationships(&state.pool).await?; Ok((StatusCode::NO_CONTENT, String::new())) @@ -6142,13 +7795,14 @@ pub async fn list_resource_diffs( )] pub async fn get_namespace_service_versions( State(state): State, + headers: axum::http::HeaderMap, Path(namespace_id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); - Ok(Json( - sqlx::query_as::<_, ServiceVersionWithDetails>( - r#" + let result = sqlx::query_as::<_, ServiceVersionWithDetails>( + r#" SELECT sv.id, sv.created_at, @@ -6184,14 +7838,17 @@ pub async fn get_namespace_service_versions( ORDER BY sv.created_at DESC LIMIT $2 OFFSET $3 "#, - ) - .bind(namespace_id) - .bind(pagination.limit) - .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + ) + .bind(namespace_id) + .bind(pagination.limit) + .bind(pagination.offset) + .fetch_all(&mut *tx) + .await + .map_err(|e| sanitize_db_error(e, "get_namespace_service_versions"))?; + tx.commit() .await - .map_err(|e| sanitize_db_error(e, "get_namespace_service_versions"))?, - )) + .map_err(|e| sanitize_db_error(e, "get_namespace_service_versions_commit"))?; + Ok(Json(result)) } /// Get service versions for a specific service definition @@ -6215,10 +7872,12 @@ pub async fn get_namespace_service_versions( )] pub async fn get_service_definition_versions( State(state): State, + headers: axum::http::HeaderMap, Path(service_definition_id): Path, Query(pagination): Query, Query(params): Query>, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let include_deprecated = params .get("include_deprecated") @@ -6266,7 +7925,7 @@ pub async fn get_service_definition_versions( .bind(service_definition_id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await } else { sqlx::query_as::<_, ServiceVersionWithDetails>( @@ -6310,13 +7969,15 @@ pub async fn get_service_definition_versions( .bind(service_definition_id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await }; - Ok(Json(query.map_err(|e| { - sanitize_db_error(e, "get_service_definition_versions") - })?)) + let result = query.map_err(|e| sanitize_db_error(e, "get_service_definition_versions"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_service_definition_versions_commit"))?; + Ok(Json(result)) } /// Create a new service version (for CI/CD pipelines) @@ -6337,6 +7998,7 @@ pub async fn get_service_definition_versions( )] pub async fn post_service_version( State(state): State, + headers: axum::http::HeaderMap, Json(data): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { // Validate git_sha format (should be 40 char hex) @@ -6347,6 +8009,16 @@ pub async fn post_service_version( )); } + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let git_sha_short = data.git_sha[..7].to_string(); // Step 1: Deprecate any existing non-pinned active version for this service+namespace @@ -6370,7 +8042,7 @@ pub async fn post_service_version( .bind(data.service_definition_id) .bind(data.namespace_id) .bind(&data.git_sha) - .fetch_all(&state.pool) + .fetch_all(&mut *tx) .await .map(|rows| rows.len() as i64) .unwrap_or(0); @@ -6413,7 +8085,7 @@ pub async fn post_service_version( .bind(data.service_definition_id) .bind(data.namespace_id) .bind(&data.git_sha) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_service_version"))?; @@ -6423,6 +8095,9 @@ pub async fn post_service_version( "Manual version: Version with git_sha {} already exists, returning existing", &data.git_sha[..7] ); + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_service_version_commit"))?; return Ok((StatusCode::OK, Json(existing))); } @@ -6438,9 +8113,10 @@ pub async fn post_service_version( path, hash, source, - source_metadata + source_metadata, + tenant_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, @@ -6468,10 +8144,15 @@ pub async fn post_service_version( .bind(&data.hash) .bind(&data.source) .bind(&data.source_metadata) - .fetch_one(&state.pool) + .bind(tenant_id) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_service_version"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_service_version_commit"))?; + Ok((StatusCode::CREATED, Json(result))) } @@ -6495,9 +8176,20 @@ pub async fn post_service_version( )] pub async fn post_deprecate_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(data): Json, ) -> Result, (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_deprecate_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let result = sqlx::query_as::<_, ServiceVersionData>( r#" UPDATE service_versions @@ -6528,10 +8220,14 @@ pub async fn post_deprecate_service_version( .bind(id) .bind(&data.deprecated_by) .bind(&data.deprecated_reason) - .fetch_one(&state.pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_deprecate_service_version"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_deprecate_service_version_commit"))?; + Ok(Json(result)) } @@ -6556,9 +8252,20 @@ pub async fn post_deprecate_service_version( )] pub async fn post_pin_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, body: Option>, ) -> Result { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_pin_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let pinned_by = body.and_then(|b| b.pinned_by.clone()); let result = sqlx::query( r#" @@ -6569,7 +8276,7 @@ pub async fn post_pin_service_version( ) .bind(id) .bind(&pinned_by) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_pin_service_version"))?; @@ -6580,6 +8287,10 @@ pub async fn post_pin_service_version( )); } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_pin_service_version_commit"))?; + Ok(StatusCode::OK) } @@ -6602,8 +8313,19 @@ pub async fn post_pin_service_version( )] pub async fn post_unpin_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "post_unpin_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let result = sqlx::query( r#" UPDATE service_versions @@ -6612,7 +8334,7 @@ pub async fn post_unpin_service_version( "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "post_unpin_service_version"))?; @@ -6623,6 +8345,10 @@ pub async fn post_unpin_service_version( )); } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "post_unpin_service_version_commit"))?; + Ok(StatusCode::OK) } @@ -6645,15 +8371,26 @@ pub async fn post_unpin_service_version( )] pub async fn delete_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_version_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + let result = sqlx::query( r#" DELETE FROM service_versions WHERE id = $1 "#, ) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_service_version"))?; @@ -6664,6 +8401,10 @@ pub async fn delete_service_version( )); } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_service_version_commit"))?; + Ok(StatusCode::NO_CONTENT) } @@ -6686,8 +8427,10 @@ pub async fn delete_service_version( )] pub async fn get_service_version( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let result = sqlx::query_as::<_, ServiceVersionWithDetails>( r#" SELECT @@ -6724,10 +8467,13 @@ pub async fn get_service_version( "#, ) .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_service_version"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_service_version_commit"))?; Ok(Json(result)) } @@ -6805,6 +8551,7 @@ fn validate_path_template(template: &str) -> PathTemplateValidation { )] pub async fn update_manifest_path_template( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, Json(body): Json, ) -> Result, (StatusCode, String)> { @@ -6820,6 +8567,16 @@ pub async fn update_manifest_path_template( )); } + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "update_manifest_path_template_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Update the service definition let result = sqlx::query( r#" @@ -6830,7 +8587,7 @@ pub async fn update_manifest_path_template( ) .bind(&body.manifest_path_template) .bind(id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "update_manifest_path_template"))?; @@ -6841,6 +8598,10 @@ pub async fn update_manifest_path_template( )); } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "update_manifest_path_template_commit"))?; + Ok(Json(validation)) } @@ -6863,8 +8624,10 @@ pub async fn update_manifest_path_template( )] pub async fn get_manifest_path_template( State(state): State, + headers: axum::http::HeaderMap, Path(id): Path, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let result = sqlx::query_scalar::<_, Option>( r#" SELECT manifest_path_template @@ -6873,10 +8636,13 @@ pub async fn get_manifest_path_template( "#, ) .bind(id) - .fetch_one(&state.readonly_pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_manifest_path_template"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_manifest_path_template_commit"))?; Ok(Json(result)) } @@ -6917,8 +8683,10 @@ pub async fn validate_path_template_endpoint( )] pub async fn get_repo_webhook( State(state): State, + headers: axum::http::HeaderMap, Path(repo_id): Path, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let result = sqlx::query_as::<_, RepoWebhookData>( r#" SELECT @@ -6938,10 +8706,13 @@ pub async fn get_repo_webhook( "#, ) .bind(repo_id) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_repo_webhook"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_repo_webhook_commit"))?; Ok(Json(result)) } @@ -6968,9 +8739,20 @@ pub async fn get_repo_webhook( )] pub async fn register_repo_webhook( State(state): State, + headers: axum::http::HeaderMap, Path(repo_id): Path, Json(body): Json, ) -> Result, (StatusCode, String)> { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "register_repo_webhook_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Get the repo info let repo = sqlx::query_as::<_, RepoData>( r#" @@ -6987,7 +8769,7 @@ pub async fn register_repo_webhook( "#, ) .bind(repo_id) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "register_repo_webhook"))? .ok_or((StatusCode::NOT_FOUND, "Repo not found".to_string()))?; @@ -7005,7 +8787,7 @@ pub async fn register_repo_webhook( r#"SELECT id, deleted_at FROM repo_webhooks WHERE repo_id = $1"#, ) .bind(repo_id) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "register_repo_webhook"))?; @@ -7110,14 +8892,14 @@ pub async fn register_repo_webhook( .bind(provider_webhook_id) .bind(&webhook_secret) .bind(&webhook_secret_hash) - .fetch_one(&state.pool) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "register_repo_webhook"))? } else { sqlx::query_scalar::<_, Uuid>( r#" - INSERT INTO repo_webhooks (repo_id, provider_webhook_id, secret, secret_hash, active) - VALUES ($1, $2, $3, $4, true) + INSERT INTO repo_webhooks (repo_id, provider_webhook_id, secret, secret_hash, active, tenant_id) + VALUES ($1, $2, $3, $4, true, $5) RETURNING id "#, ) @@ -7125,11 +8907,16 @@ pub async fn register_repo_webhook( .bind(provider_webhook_id) .bind(&webhook_secret) .bind(&webhook_secret_hash) - .fetch_one(&state.pool) + .bind(tenant_id) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "register_repo_webhook"))? }; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "register_repo_webhook_commit"))?; + Ok(Json(RegisterRepoWebhookResponse { webhook_id, provider_webhook_id, @@ -7160,9 +8947,20 @@ pub async fn register_repo_webhook( )] pub async fn delete_repo_webhook( State(state): State, + headers: axum::http::HeaderMap, Path(repo_id): Path, body: Option>, ) -> Result { + let tenant_id = extract_tenant_from_request(&state.pool, &headers).await?; + + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "delete_repo_webhook_begin"))?; + + set_tenant_context(&mut tx, tenant_id).await?; + // Load webhook + repo info so we can optionally delete the remote GitHub hook. let webhook = sqlx::query_as::<_, (Option, String, String, String)>( r#" @@ -7177,7 +8975,7 @@ pub async fn delete_repo_webhook( "#, ) .bind(repo_id) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_repo_webhook"))? .ok_or((StatusCode::NOT_FOUND, "Webhook not found".to_string()))?; @@ -7242,7 +9040,7 @@ pub async fn delete_repo_webhook( "#, ) .bind(repo_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "delete_repo_webhook"))?; @@ -7251,6 +9049,10 @@ pub async fn delete_repo_webhook( return Err((StatusCode::NOT_FOUND, "Webhook not found".to_string())); } + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "delete_repo_webhook_commit"))?; + Ok(StatusCode::OK) } @@ -7320,9 +9122,9 @@ pub async fn receive_github_webhook( // Find the webhook and its secret. // Prefer repo_id from callback URL query param; fall back to org/repo lookup. let webhook_info = if let Some(repo_id) = repo_id { - sqlx::query_as::<_, (Uuid, Uuid, Option)>( + sqlx::query_as::<_, (Uuid, Uuid, Option, Uuid)>( r#" - SELECT gw.id, gw.repo_id, gw.secret + SELECT gw.id, gw.repo_id, gw.secret, gw.tenant_id FROM repo_webhooks gw WHERE gw.repo_id = $1 AND gw.deleted_at IS NULL AND gw.active = true @@ -7333,9 +9135,9 @@ pub async fn receive_github_webhook( .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))? } else { - sqlx::query_as::<_, (Uuid, Uuid, Option)>( + sqlx::query_as::<_, (Uuid, Uuid, Option, Uuid)>( r#" - SELECT gw.id, gw.repo_id, gw.secret + SELECT gw.id, gw.repo_id, gw.secret, gw.tenant_id FROM repo_webhooks gw JOIN repos r ON r.id = gw.repo_id WHERE LOWER(r.org) = LOWER($1) AND LOWER(r.repo) = LOWER($2) @@ -7353,7 +9155,7 @@ pub async fn receive_github_webhook( "No webhook registered for this repo".to_string(), ))?; - let (webhook_id, _repo_id, webhook_secret) = webhook_info; + let (webhook_id, _repo_id, webhook_secret, tenant_id) = webhook_info; let webhook_secret = webhook_secret.ok_or(( StatusCode::INTERNAL_SERVER_ERROR, "Webhook secret is not configured for this repo".to_string(), @@ -7388,12 +9190,20 @@ pub async fn receive_github_webhook( )); } + // Start transaction and set tenant context for all subsequent queries + let mut tx = state + .pool + .begin() + .await + .map_err(|e| sanitize_db_error(e, "receive_github_webhook_begin"))?; + set_tenant_context(&mut tx, tenant_id).await?; + // Create webhook event record let event_id = sqlx::query_scalar::<_, Uuid>( r#" INSERT INTO repo_webhook_events - (webhook_id, delivery_id, event_type, ref, before_sha, after_sha, pusher) - VALUES ($1, $2, $3, $4, $5, $6, $7) + (webhook_id, delivery_id, event_type, ref, before_sha, after_sha, pusher, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id "#, ) @@ -7404,7 +9214,8 @@ pub async fn receive_github_webhook( .bind(&payload.before) .bind(&payload.after) .bind(&payload.pusher.name) - .fetch_one(&state.pool) + .bind(tenant_id) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7426,7 +9237,7 @@ pub async fn receive_github_webhook( .bind(org) .bind(repo_name) .bind(&branch) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7497,7 +9308,7 @@ pub async fn receive_github_webhook( ) .bind(namespace) .bind(cluster) - .fetch_optional(&state.readonly_pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7591,7 +9402,7 @@ pub async fn receive_github_webhook( .bind(service_def_id) .bind(ns_id) .bind(&payload.after) - .fetch_all(&state.pool) + .fetch_all(&mut *tx) .await .map(|rows| rows.len() as i64) .unwrap_or(0); @@ -7620,7 +9431,7 @@ pub async fn receive_github_webhook( .bind(service_def_id) .bind(ns_id) .bind(&payload.after) - .fetch_optional(&state.pool) + .fetch_optional(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7637,7 +9448,7 @@ pub async fn receive_github_webhook( .bind(is_directory) .bind(event_id) .bind(existing_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7652,8 +9463,8 @@ pub async fn receive_github_webhook( sqlx::query_scalar::<_, Uuid>( r#" INSERT INTO service_versions - (service_definition_id, namespace_id, version, git_sha, git_sha_short, path, is_directory_pattern, hash, source, webhook_event_id) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'webhook', $9) + (service_definition_id, namespace_id, version, git_sha, git_sha_short, path, is_directory_pattern, hash, source, webhook_event_id, tenant_id) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'webhook', $9, $10) RETURNING id "#, ) @@ -7666,7 +9477,8 @@ pub async fn receive_github_webhook( .bind(is_directory) .bind("pending") // Hash will be computed later when manifests are fetched .bind(event_id) - .fetch_one(&state.pool) + .bind(tenant_id) + .fetch_one(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))? }; @@ -7696,7 +9508,7 @@ pub async fn receive_github_webhook( .bind(&matched_paths) .bind(&updated_versions) .bind(event_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; @@ -7707,10 +9519,15 @@ pub async fn receive_github_webhook( "#, ) .bind(webhook_id) - .execute(&state.pool) + .execute(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "receive_github_webhook"))?; + // Commit the transaction + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "receive_github_webhook_commit"))?; + Ok(Json(json!({ "status": "ok", "event_id": event_id, @@ -8669,9 +10486,11 @@ mod path_template_tests { )] pub async fn get_webhook_events( State(state): State, + headers: axum::http::HeaderMap, Path(repo_id): Path, Query(pagination): Query, ) -> Result>, (StatusCode, String)> { + let (mut tx, _tenant_id, _tenant_domain) = get_tenant_tx(&state.pool, &headers).await?; let pagination = pagination.validate(); let events = sqlx::query_as::<_, RepoWebhookEvent>( @@ -8700,9 +10519,12 @@ pub async fn get_webhook_events( .bind(repo_id) .bind(pagination.limit) .bind(pagination.offset) - .fetch_all(&state.readonly_pool) + .fetch_all(&mut *tx) .await .map_err(|e| sanitize_db_error(e, "get_webhook_events"))?; + tx.commit() + .await + .map_err(|e| sanitize_db_error(e, "get_webhook_events_commit"))?; Ok(Json(events)) } diff --git a/hive-hq/api/src/lib.rs b/hive-hq/api/src/lib.rs index 0b0fd07..1078ed0 100644 --- a/hive-hq/api/src/lib.rs +++ b/hive-hq/api/src/lib.rs @@ -7,6 +7,10 @@ pub mod util; #[cfg(test)] mod auth_tests; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + #[derive(Clone)] pub struct ServerState { pub pool: sqlx::Pool, @@ -24,4 +28,6 @@ pub struct ServerState { /// This must be externally reachable by GitHub, e.g.: /// https://hive-hq.example.com/api/webhooks/github pub github_webhook_callback_url: Option, + /// In-memory, per-tenant encrypted secret cache. No DB persistence. + pub secret_cache: Arc, Vec, i16, chrono::DateTime)>>>, } diff --git a/hive-hq/api/src/main.rs b/hive-hq/api/src/main.rs index 9730d41..00e43a0 100644 --- a/hive-hq/api/src/main.rs +++ b/hive-hq/api/src/main.rs @@ -34,6 +34,15 @@ pub struct ServerState { jwt_secret_bytes: Vec, read_replica_wait_in_ms: u64, github_webhook_callback_url: Option, + /// In-memory, per-tenant encrypted secret cache. No DB persistence. + secret_cache: std::sync::Arc< + tokio::sync::RwLock< + std::collections::HashMap< + (uuid::Uuid, String), + (Vec, Vec, i16, chrono::DateTime), + >, + >, + >, } /// Decode JWT secret from string. @@ -362,6 +371,9 @@ async fn main() -> Result<(), Box> { jwt_secret_bytes, read_replica_wait_in_ms: read_replica_wait_in_ms.parse().unwrap_or(75), github_webhook_callback_url, + secret_cache: std::sync::Arc::new(tokio::sync::RwLock::new( + std::collections::HashMap::new(), + )), }; let cors = CorsLayer::new().allow_origin(Any); @@ -381,7 +393,10 @@ async fn main() -> Result<(), Box> { "/api/auth/bootstrap/status", get(handler::ui_auth_bootstrap_status), ) - .route("/api/auth/login", post(handler::ui_auth_login)); + .route("/api/auth/login", post(handler::ui_auth_login)) + .route("/api/tenants/register", post(handler::register_tenant)) + // temporary alias to handle environments that double-prefix /api + .route("/api/api/tenants/register", post(handler::register_tenant)); #[cfg(feature = "dev-mode")] let public_routes = public_routes.route("/api/free-token", get(handler::free_token)); @@ -411,6 +426,15 @@ async fn main() -> Result<(), Box> { .layer(cors) .route("/api/auth/me", get(handler::ui_auth_me)) .route("/api/auth/logout", post(handler::ui_auth_logout)) + .route( + "/api/secrets", + get(handler::list_secrets).post(handler::create_secret), + ) + .route("/api/secrets/{purpose}", delete(handler::delete_secret)) + .route( + "/api/secrets/{purpose}/encrypted", + get(handler::get_encrypted_secret), + ) .route("/api/cluster-defaults", get(handler::get_cluster_defaults)) .route( "/api/clusters", diff --git a/hive-hq/api/tests/acl_tests.rs b/hive-hq/api/tests/acl_tests.rs index 3bdcb80..adace74 100644 --- a/hive-hq/api/tests/acl_tests.rs +++ b/hive-hq/api/tests/acl_tests.rs @@ -7,11 +7,12 @@ use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tower::ServiceExt; use types::Claim; +use uuid::Uuid; // Tests must run serially to avoid port-forward conflicts // Run with: cargo test --test acl_tests -- --ignored --nocapture --test-threads=1 -fn generate_test_jwt(jwt_secret: &str, roles: Vec) -> String { +fn generate_test_jwt(jwt_secret: &str, roles: Vec, tenant_id: Uuid) -> String { let secret_bytes = jwt_secret.as_bytes(); let expiration = @@ -21,6 +22,7 @@ fn generate_test_jwt(jwt_secret: &str, roles: Vec) -> String { email: "test@galleybytes.com".to_string(), exp: expiration.as_secs() as usize, roles, + tenant_id: tenant_id.to_string(), }; encode( @@ -95,7 +97,7 @@ async fn test_aversion_endpoint_requires_role() { let app = env.create_test_app(); // Create token without aversion role - let token = generate_test_jwt(&env.jwt_secret, vec!["some-other-role".to_string()]); + let token = generate_test_jwt(&env.jwt_secret, vec!["some-other-role".to_string()], env.tenant_id); let request = Request::builder() .uri("/api/aversion/clusters/test-cluster/namespaces") @@ -122,7 +124,7 @@ async fn test_aversion_endpoint_accepts_aversion_role() { let app = env.create_test_app(); // Create token with aversion role - let token = generate_test_jwt(&env.jwt_secret, vec!["aversion".to_string()]); + let token = generate_test_jwt(&env.jwt_secret, vec!["aversion".to_string()], env.tenant_id); let request = Request::builder() .uri("/api/aversion/clusters/test-cluster/namespaces") @@ -150,7 +152,7 @@ async fn test_aversion_endpoint_accepts_admin_role() { let app = env.create_test_app(); // Create token with admin role - let token = generate_test_jwt(&env.jwt_secret, vec!["admin".to_string()]); + let token = generate_test_jwt(&env.jwt_secret, vec!["admin".to_string()], env.tenant_id); let request = Request::builder() .uri("/api/aversion/clusters/test-cluster/namespaces") @@ -178,7 +180,7 @@ async fn test_admin_role_accesses_protected_endpoints() { let app = env.create_test_app(); // Create token with admin role - let token = generate_test_jwt(&env.jwt_secret, vec!["admin".to_string()]); + let token = generate_test_jwt(&env.jwt_secret, vec!["admin".to_string()], env.tenant_id); let request = Request::builder() .uri("/api/clusters") @@ -205,7 +207,7 @@ async fn test_aversion_role_accesses_protected_endpoints() { let app = env.create_test_app(); // Create token with aversion role - let token = generate_test_jwt(&env.jwt_secret, vec!["aversion".to_string()]); + let token = generate_test_jwt(&env.jwt_secret, vec!["aversion".to_string()], env.tenant_id); let request = Request::builder() .uri("/api/clusters") @@ -235,6 +237,7 @@ async fn test_multiple_roles() { let token = generate_test_jwt( &env.jwt_secret, vec!["admin".to_string(), "aversion".to_string()], + env.tenant_id, ); let request = Request::builder() diff --git a/hive-hq/api/tests/api_integration_tests.rs b/hive-hq/api/tests/api_integration_tests.rs index b9849ae..41711b0 100644 --- a/hive-hq/api/tests/api_integration_tests.rs +++ b/hive-hq/api/tests/api_integration_tests.rs @@ -17,7 +17,7 @@ async fn test_get_clusters_with_bulk_data() { // Create 200 test clusters let mut cluster_ids = Vec::new(); for _i in 0..200 { - let id = create_test_cluster(&env.pool).await; + let id = create_test_cluster(&env.pool, env.tenant_id).await; cluster_ids.push(id); } @@ -56,7 +56,7 @@ async fn test_post_cluster_group_with_auth() { // Create a cluster group let group_name = format!("test-group-{}", Uuid::new_v4()); - let group_id = create_test_cluster_group(&env.pool, &group_name).await; + let group_id = create_test_cluster_group(&env.pool, &group_name, env.tenant_id).await; // Verify it was created let result: (Uuid, String) = @@ -85,8 +85,8 @@ async fn test_put_service_definition_update() { .expect("Failed to generate JWT"); // Create test data - let (_, branch_id) = create_test_repo(&env.pool).await; - let bt_id = create_test_service_definition(&env.pool, branch_id, "original-name").await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; + let bt_id = create_test_service_definition(&env.pool, branch_id, "original-name", env.tenant_id).await; // Update the build target let new_name = format!("updated-name-{}", Uuid::new_v4()); @@ -123,7 +123,7 @@ async fn test_delete_cluster_group() { // Create a cluster group let group_name = format!("test-group-{}", Uuid::new_v4()); - let group_id = create_test_cluster_group(&env.pool, &group_name).await; + let group_id = create_test_cluster_group(&env.pool, &group_name, env.tenant_id).await; // Verify it exists let count_before: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM cluster_groups WHERE id = $1") @@ -163,7 +163,7 @@ async fn test_authentication_failure() { // but would fail at API level (not testing full API stack here) // Create data without auth to verify DB operations work - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; let result: (Uuid,) = sqlx::query_as("SELECT id FROM clusters WHERE id = $1") .bind(cluster_id) @@ -184,7 +184,7 @@ async fn test_bulk_operations_with_300_entries() { .await .expect("Failed to setup test environment"); - let fixtures = create_bulk_fixtures(&env.pool, 300).await; + let fixtures = create_bulk_fixtures(&env.pool, 300, env.tenant_id).await; assert_eq!(fixtures.service_definition_ids.len(), 300); assert_eq!(fixtures.release_ids.len(), 300); @@ -231,7 +231,7 @@ async fn test_complex_query_with_joins() { .await .expect("Failed to setup test environment"); - let fixtures = create_bulk_fixtures(&env.pool, 100).await; + let fixtures = create_bulk_fixtures(&env.pool, 100, env.tenant_id).await; // Complex query joining multiple tables let results: Vec<(Uuid, String, String)> = sqlx::query_as( @@ -274,8 +274,8 @@ async fn test_data_integrity_after_crud_operations() { .expect("Failed to generate JWT"); // CREATE - let (_, branch_id) = create_test_repo(&env.pool).await; - let bt_id = create_test_service_definition(&env.pool, branch_id, "test-service").await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; + let bt_id = create_test_service_definition(&env.pool, branch_id, "test-service", env.tenant_id).await; // READ let read_result: (String,) = @@ -333,7 +333,8 @@ async fn test_concurrent_operations() { let mut tasks = Vec::new(); for _ in 0..50 { let pool = env.pool.clone(); - let task = tokio::spawn(async move { create_test_cluster(&pool).await }); + let tenant_id = env.tenant_id; + let task = tokio::spawn(async move { create_test_cluster(&pool, tenant_id).await }); tasks.push(task); } diff --git a/hive-hq/api/tests/error_path_tests.rs b/hive-hq/api/tests/error_path_tests.rs index c9eeeb0..5014e95 100644 --- a/hive-hq/api/tests/error_path_tests.rs +++ b/hive-hq/api/tests/error_path_tests.rs @@ -90,10 +90,12 @@ async fn test_foreign_key_violation_on_namespace() { let non_existent_cluster_id = Uuid::new_v4(); // Try to create namespace with non-existent cluster - let result = sqlx::query("INSERT INTO namespaces (id, name, cluster_id) VALUES ($1, $2, $3)") + // Even with valid tenant_id, the FK constraint should fail + let result = sqlx::query("INSERT INTO namespaces (id, name, cluster_id, tenant_id) VALUES ($1, $2, $3, $4)") .bind(Uuid::new_v4()) .bind("orphan-namespace") .bind(non_existent_cluster_id) + .bind(env.tenant_id) .execute(&env.pool) .await; @@ -161,7 +163,7 @@ async fn test_concurrent_updates_on_same_record() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; // Launch 10 concurrent updates to the same cluster let mut tasks = Vec::new(); @@ -251,9 +253,9 @@ async fn test_batch_operations_near_parameter_limit() { // With 4 parameters per insert, we can do ~8000 at once // Let's try with 2000 to be safe - let cluster_id = create_test_cluster(&env.pool).await; - let namespace_id = create_test_namespace(&env.pool, cluster_id).await; - let (_, _branch_id) = create_test_repo(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; + let namespace_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; + let (_, _branch_id) = create_test_repo(&env.pool, env.tenant_id).await; let mut release_names = Vec::new(); for i in 0..2000 { @@ -284,17 +286,18 @@ async fn test_transaction_rollback_with_foreign_keys() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; // Start transaction let mut tx = env.pool.begin().await.expect("Failed to start transaction"); // Create namespace in transaction let namespace_id = Uuid::new_v4(); - sqlx::query("INSERT INTO namespaces (id, name, cluster_id) VALUES ($1, $2, $3)") + sqlx::query("INSERT INTO namespaces (id, name, cluster_id, tenant_id) VALUES ($1, $2, $3, $4)") .bind(namespace_id) .bind("tx-namespace") .bind(cluster_id) + .bind(env.tenant_id) .execute(&mut *tx) .await .expect("Failed to insert namespace"); @@ -379,16 +382,17 @@ async fn test_empty_string_vs_null() { .await .expect("Failed to setup test environment"); - let (_, branch_id) = create_test_repo(&env.pool).await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; // Insert build target with empty name (should work - no constraint) let result = sqlx::query( - "INSERT INTO service_definitions (id, name, repo_branch_id, source_branch_requirements) VALUES ($1, $2, $3, $4)" + "INSERT INTO service_definitions (id, name, repo_branch_id, source_branch_requirements, tenant_id) VALUES ($1, $2, $3, $4, $5)" ) .bind(Uuid::new_v4()) .bind("") // empty string .bind(branch_id) .bind("[]") + .bind(env.tenant_id) .execute(&env.pool) .await; @@ -454,16 +458,17 @@ async fn test_special_characters_in_metadata() { .expect("Failed to setup test environment"); let special_metadata = - r#"{"key": "value with \"quotes\"", "emoji": "🚀", "newline": "line1\nline2"}"#; + r#"{"key": "value with \"quotes\"", "emoji": "rocket", "newline": "line1\nline2"}"#; let result = sqlx::query( - "INSERT INTO clusters (id, name, metadata, version, kubernetes_version) VALUES ($1, $2, $3, $4, $5)" + "INSERT INTO clusters (id, name, metadata, version, kubernetes_version, tenant_id) VALUES ($1, $2, $3, $4, $5, $6)" ) .bind(Uuid::new_v4()) .bind("special-cluster") .bind(special_metadata) .bind("1.0") .bind("1.28") + .bind(env.tenant_id) .execute(&env.pool) .await; @@ -523,7 +528,7 @@ async fn test_optimistic_locking_pattern() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; // Read with timestamp let original_updated: chrono::DateTime = diff --git a/hive-hq/api/tests/http_api_tests.rs b/hive-hq/api/tests/http_api_tests.rs index 4616720..2435dc5 100644 --- a/hive-hq/api/tests/http_api_tests.rs +++ b/hive-hq/api/tests/http_api_tests.rs @@ -51,7 +51,7 @@ async fn test_get_clusters_with_auth() { // Create test clusters for _ in 0..10 { - create_test_cluster(&env.pool).await; + create_test_cluster(&env.pool, env.tenant_id).await; } let app = env.create_test_app(); @@ -205,6 +205,7 @@ async fn test_wrong_signature_returns_401() { email: "test@galleybytes.com".to_string(), exp: expiration.as_secs() as usize, roles: vec!["admin".to_string()], + tenant_id: env.tenant_id.to_string(), }; let wrong_token = encode( @@ -332,7 +333,7 @@ async fn test_get_cluster_groups_with_performance() { // Create 50 cluster groups with unique names for _ in 0..50 { let unique_name = format!("perf-group-{}", Uuid::new_v4()); - create_test_cluster_group(&env.pool, &unique_name).await; + create_test_cluster_group(&env.pool, &unique_name, env.tenant_id).await; } let app = env.create_test_app(); @@ -393,7 +394,7 @@ async fn test_get_clusters_bulk_with_performance() { println!("Creating 200 test clusters..."); let create_start = Instant::now(); for _ in 0..200 { - create_test_cluster(&env.pool).await; + create_test_cluster(&env.pool, env.tenant_id).await; } let create_elapsed = create_start.elapsed(); println!("✓ Created 200 clusters in {:?}", create_elapsed); @@ -459,7 +460,7 @@ async fn test_delete_cluster() { .expect("Failed to setup test environment"); // Create a test cluster - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; let app = env.create_test_app(); let token = env diff --git a/hive-hq/api/tests/integration/test_env.rs b/hive-hq/api/tests/integration/test_env.rs index 5440c46..e29aa18 100644 --- a/hive-hq/api/tests/integration/test_env.rs +++ b/hive-hq/api/tests/integration/test_env.rs @@ -5,7 +5,7 @@ use axum::{ Router, }; use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; -use sqlx::PgPool; +use sqlx::{Executor, PgPool}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tokio::time::sleep; use types::Claim; @@ -15,6 +15,8 @@ pub struct TestEnvironment { pub namespace: String, pub pool: PgPool, pub jwt_secret: String, + pub tenant_id: Uuid, + pub tenant_subdomain: String, } impl TestEnvironment { @@ -47,15 +49,46 @@ impl TestEnvironment { let jwt_secret = "test_jwt_secret_for_integration_tests_minimum_length_required".to_string(); - println!("✓ Test environment ready"); + // Create test tenant for isolation + let tenant_id = Uuid::new_v4(); + let tenant_subdomain = format!("test-{}", &tenant_id.to_string()[..8]); + + sqlx::query( + r#" + INSERT INTO tenants (id, name, domain) + VALUES ($1, $2, $3) + ON CONFLICT (domain) DO NOTHING + "#, + ) + .bind(tenant_id) + .bind(&format!("Test Tenant {}", tenant_subdomain)) + .bind(&tenant_subdomain) + .execute(&pool) + .await?; + + println!("Created test tenant: {} ({})", tenant_subdomain, tenant_id); + println!("Test environment ready"); Ok(Self { namespace, pool, jwt_secret, + tenant_id, + tenant_subdomain, }) } + /// Set tenant context for RLS policies. Call this before any tenant-scoped queries. + pub async fn set_tenant_context<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: Executor<'e, Database = sqlx::Postgres>, + { + sqlx::query(&format!("SET LOCAL app.tenant_id = '{}'", self.tenant_id)) + .execute(executor) + .await?; + Ok(()) + } + pub async fn cleanup(self) { println!("Cleaning up test environment..."); @@ -74,6 +107,7 @@ impl TestEnvironment { email: email.to_string(), exp: expiration.as_secs() as usize, roles: vec!["admin".to_string()], + tenant_id: self.tenant_id.to_string(), }; let token = encode( @@ -92,6 +126,7 @@ impl TestEnvironment { email: email.to_string(), exp: expiration.as_secs() as usize, roles: vec!["admin".to_string()], + tenant_id: self.tenant_id.to_string(), }; let token = encode( @@ -109,6 +144,9 @@ impl TestEnvironment { // Import the handler module from the main crate use api::handler; use api::ServerState; + use std::collections::HashMap; + use std::sync::Arc; + use tokio::sync::RwLock; use tower_http::cors::{Any, CorsLayer}; let server_state = ServerState { @@ -123,6 +161,7 @@ impl TestEnvironment { jwt_secret_bytes: self.jwt_secret.clone().into_bytes(), read_replica_wait_in_ms: 0, // No wait for tests github_webhook_callback_url: None, + secret_cache: Arc::new(RwLock::new(HashMap::new())), }; let cors = CorsLayer::new().allow_origin(Any); @@ -229,55 +268,58 @@ impl TestEnvironment { } } -// Test fixture helpers -pub async fn create_test_cluster(pool: &PgPool) -> Uuid { +// Test fixture helpers - all require tenant context set first +pub async fn create_test_cluster(pool: &PgPool, tenant_id: Uuid) -> Uuid { let id = Uuid::new_v4(); let name = format!("test-cluster-{}", Uuid::new_v4()); sqlx::query( r#" - INSERT INTO clusters (id, name, metadata, version, kubernetes_version) - VALUES ($1, $2, 'test', '1.0', '1.28') + INSERT INTO clusters (id, name, metadata, version, kubernetes_version, tenant_id) + VALUES ($1, $2, 'test', '1.0', '1.28', $3) "#, ) .bind(id) .bind(name) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test cluster"); id } -pub async fn create_test_namespace(pool: &PgPool, cluster_id: Uuid) -> Uuid { +pub async fn create_test_namespace(pool: &PgPool, cluster_id: Uuid, tenant_id: Uuid) -> Uuid { let id = Uuid::new_v4(); let name = format!("test-namespace-{}", Uuid::new_v4()); sqlx::query( r#" - INSERT INTO namespaces (id, name, cluster_id) - VALUES ($1, $2, $3) + INSERT INTO namespaces (id, name, cluster_id, tenant_id) + VALUES ($1, $2, $3, $4) "#, ) .bind(id) .bind(name) .bind(cluster_id) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test namespace"); id } -pub async fn create_test_repo(pool: &PgPool) -> (Uuid, Uuid) { +pub async fn create_test_repo(pool: &PgPool, tenant_id: Uuid) -> (Uuid, Uuid) { let repo_id = Uuid::new_v4(); let org = format!("test-org-{}", Uuid::new_v4()); let repo = format!("test-repo-{}", Uuid::new_v4()); sqlx::query( r#" - INSERT INTO repos (id, org, repo) - VALUES ($1, $2, $3) + INSERT INTO repos (id, org, repo, tenant_id) + VALUES ($1, $2, $3, $4) "#, ) .bind(repo_id) .bind(&org) .bind(&repo) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test repo"); @@ -285,12 +327,13 @@ pub async fn create_test_repo(pool: &PgPool) -> (Uuid, Uuid) { let branch_id = Uuid::new_v4(); sqlx::query( r#" - INSERT INTO repo_branches (id, branch, repo_id) - VALUES ($1, 'main', $2) + INSERT INTO repo_branches (id, branch, repo_id, tenant_id) + VALUES ($1, 'main', $2, $3) "#, ) .bind(branch_id) .bind(repo_id) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test repo branch"); @@ -302,33 +345,36 @@ pub async fn create_test_service_definition( pool: &PgPool, repo_branch_id: Uuid, name: &str, + tenant_id: Uuid, ) -> Uuid { let id = Uuid::new_v4(); sqlx::query( r#" - INSERT INTO service_definitions (id, name, repo_branch_id, source_branch_requirements) - VALUES ($1, $2, $3, '[]') + INSERT INTO service_definitions (id, name, repo_branch_id, source_branch_requirements, tenant_id) + VALUES ($1, $2, $3, '[]', $4) "#, ) .bind(id) .bind(name) .bind(repo_branch_id) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test build target"); id } -pub async fn create_test_cluster_group(pool: &PgPool, name: &str) -> Uuid { +pub async fn create_test_cluster_group(pool: &PgPool, name: &str, tenant_id: Uuid) -> Uuid { let id = Uuid::new_v4(); sqlx::query( r#" - INSERT INTO cluster_groups (id, name) - VALUES ($1, $2) + INSERT INTO cluster_groups (id, name, tenant_id) + VALUES ($1, $2, $3) "#, ) .bind(id) .bind(name) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test cluster group"); @@ -340,21 +386,23 @@ pub async fn create_test_release( namespace_id: Uuid, repo_branch_id: Uuid, name: &str, + tenant_id: Uuid, ) -> Uuid { let id = Uuid::new_v4(); sqlx::query( r#" INSERT INTO releases ( id, namespace_id, name, path, repo_branch_id, - hash, version, git_sha + hash, version, git_sha, tenant_id ) - VALUES ($1, $2, $3, '/test/path', $4, 'hash123', 'v1.0', 'abc123') + VALUES ($1, $2, $3, '/test/path', $4, 'hash123', 'v1.0', 'abc123', $5) "#, ) .bind(id) .bind(namespace_id) .bind(name) .bind(repo_branch_id) + .bind(tenant_id) .execute(pool) .await .expect("Failed to create test release"); @@ -362,13 +410,13 @@ pub async fn create_test_release( } // Bulk fixture creation for performance testing -pub async fn create_bulk_fixtures(pool: &PgPool, count: usize) -> BulkFixtures { +pub async fn create_bulk_fixtures(pool: &PgPool, count: usize, tenant_id: Uuid) -> BulkFixtures { println!("Creating {} bulk test fixtures...", count); // Create base cluster and repo - let cluster_id = create_test_cluster(pool).await; - let namespace_id = create_test_namespace(pool, cluster_id).await; - let (repo_id, branch_id) = create_test_repo(pool).await; + let cluster_id = create_test_cluster(pool, tenant_id).await; + let namespace_id = create_test_namespace(pool, cluster_id, tenant_id).await; + let (repo_id, branch_id) = create_test_repo(pool, tenant_id).await; let mut service_definition_ids = Vec::new(); let mut release_ids = Vec::new(); @@ -376,15 +424,22 @@ pub async fn create_bulk_fixtures(pool: &PgPool, count: usize) -> BulkFixtures { // Batch create build targets and releases for i in 0..count { let bt_id = - create_test_service_definition(pool, branch_id, &format!("service-{}", i)).await; + create_test_service_definition(pool, branch_id, &format!("service-{}", i), tenant_id) + .await; service_definition_ids.push(bt_id); - let rel_id = - create_test_release(pool, namespace_id, branch_id, &format!("release-{}", i)).await; + let rel_id = create_test_release( + pool, + namespace_id, + branch_id, + &format!("release-{}", i), + tenant_id, + ) + .await; release_ids.push(rel_id); } - println!("✓ Created {} build targets and releases", count); + println!("Created {} build targets and releases", count); BulkFixtures { cluster_id, diff --git a/hive-hq/api/tests/integration_tests.rs b/hive-hq/api/tests/integration_tests.rs index 63d6e9e..c9301c4 100644 --- a/hive-hq/api/tests/integration_tests.rs +++ b/hive-hq/api/tests/integration_tests.rs @@ -13,13 +13,20 @@ async fn test_sync_cluster_releases_basic_flow() { .await .expect("Failed to setup test environment"); - // Create test fixtures - let cluster_id = create_test_cluster(&env.pool).await; - let namespace_id = create_test_namespace(&env.pool, cluster_id).await; - let (_, branch_id) = create_test_repo(&env.pool).await; + // Create test fixtures with tenant_id + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; + let namespace_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; // Create initial release - let release_id = create_test_release(&env.pool, namespace_id, branch_id, "test-app").await; + let release_id = create_test_release( + &env.pool, + namespace_id, + branch_id, + "test-app", + env.tenant_id, + ) + .await; // Verify release was created let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM releases WHERE id = $1") @@ -40,27 +47,28 @@ async fn test_transaction_atomicity_on_error() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; - let namespace_id = create_test_namespace(&env.pool, cluster_id).await; - let (_, branch_id) = create_test_repo(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; + let namespace_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; // Start a transaction let mut tx = env.pool.begin().await.expect("Failed to start transaction"); - // Insert a release within the transaction + // Insert a release within the transaction (includes tenant_id) let release_id = Uuid::new_v4(); sqlx::query( r#" INSERT INTO releases ( id, namespace_id, name, path, repo_branch_id, - hash, version, git_sha + hash, version, git_sha, tenant_id ) - VALUES ($1, $2, 'test-app', '/test/path', $3, 'hash123', 'v1.0', 'abc123') + VALUES ($1, $2, 'test-app', '/test/path', $3, 'hash123', 'v1.0', 'abc123', $4) "#, ) .bind(release_id) .bind(namespace_id) .bind(branch_id) + .bind(env.tenant_id) .execute(&mut *tx) .await .expect("Failed to insert release"); @@ -90,9 +98,9 @@ async fn test_batch_operations_within_limits() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; - let namespace_id = create_test_namespace(&env.pool, cluster_id).await; - let (_, branch_id) = create_test_repo(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; + let namespace_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; // Create 100 releases to test batch operations for i in 0..100 { @@ -101,6 +109,7 @@ async fn test_batch_operations_within_limits() { namespace_id, branch_id, &format!("test-app-{}", i), + env.tenant_id, ) .await; } @@ -141,17 +150,17 @@ async fn test_namespace_isolation() { .await .expect("Failed to setup test environment"); - let cluster_id = create_test_cluster(&env.pool).await; + let cluster_id = create_test_cluster(&env.pool, env.tenant_id).await; // Create two namespaces - let ns1_id = create_test_namespace(&env.pool, cluster_id).await; - let ns2_id = create_test_namespace(&env.pool, cluster_id).await; + let ns1_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; + let ns2_id = create_test_namespace(&env.pool, cluster_id, env.tenant_id).await; - let (_, branch_id) = create_test_repo(&env.pool).await; + let (_, branch_id) = create_test_repo(&env.pool, env.tenant_id).await; // Create releases in both namespaces - create_test_release(&env.pool, ns1_id, branch_id, "app-ns1").await; - create_test_release(&env.pool, ns2_id, branch_id, "app-ns2").await; + create_test_release(&env.pool, ns1_id, branch_id, "app-ns1", env.tenant_id).await; + create_test_release(&env.pool, ns2_id, branch_id, "app-ns2", env.tenant_id).await; // Verify namespace 1 only has its own release let ns1_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM releases WHERE namespace_id = $1") diff --git a/hive-hq/types/src/lib.rs b/hive-hq/types/src/lib.rs index 26b50eb..6234f75 100644 --- a/hive-hq/types/src/lib.rs +++ b/hive-hq/types/src/lib.rs @@ -552,6 +552,7 @@ pub struct DeleteId { #[cfg_attr(feature = "api", derive(sqlx::FromRow, utoipa::ToSchema))] pub struct Claim { pub email: String, + pub tenant_id: String, pub exp: usize, pub roles: Vec, } diff --git a/hive-hq/ui/src/App.tsx b/hive-hq/ui/src/App.tsx index ea3642e..1a70ea7 100644 --- a/hive-hq/ui/src/App.tsx +++ b/hive-hq/ui/src/App.tsx @@ -16,7 +16,9 @@ import { ClusterGroupsPage, ClusterGroupDetailPage, ReleaseDetailPage, - LoginPage + LoginPage, + RegisterPage, + SettingsPage } from '@/pages'; const queryClient = new QueryClient({ @@ -53,8 +55,9 @@ function App() { - {/* Login page - outside main layout */} + {/* Public routes - outside main layout */} } /> + } /> {/* Main app routes */} }> @@ -69,6 +72,7 @@ function App() { } /> } /> } /> + } />

404 - Not Found

} />
diff --git a/hive-hq/ui/src/components/Alert.tsx b/hive-hq/ui/src/components/Alert.tsx index b70bd28..7e29df0 100644 --- a/hive-hq/ui/src/components/Alert.tsx +++ b/hive-hq/ui/src/components/Alert.tsx @@ -5,7 +5,8 @@ type AlertType = 'success' | 'error' | 'warning' | 'info'; interface AlertProps { type: AlertType; title?: string; - message: string; + message?: string; + children?: React.ReactNode; onDismiss?: () => void; } @@ -36,7 +37,7 @@ const alertStyles: Record
{title &&

{title}

} -

{message}

+ {message &&

{message}

} + {children &&
{children}
}
{onDismiss && (
diff --git a/hive-hq/ui/src/layouts/MainLayout.tsx b/hive-hq/ui/src/layouts/MainLayout.tsx index 0a2fbef..34e2083 100644 --- a/hive-hq/ui/src/layouts/MainLayout.tsx +++ b/hive-hq/ui/src/layouts/MainLayout.tsx @@ -8,7 +8,8 @@ import { Menu, X, User, - LogOut + LogOut, + Settings } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { ThemeToggle } from '@/components'; @@ -21,6 +22,7 @@ const navigation = [ { name: 'Services', href: '/services', icon: Layers }, { name: 'Repositories', href: '/repos', icon: FolderGit2 }, { name: 'Cluster Groups', href: '/cluster-groups', icon: GitBranch }, + { name: 'Settings', href: '/settings', icon: Settings }, ]; export function MainLayout() { @@ -37,7 +39,7 @@ export function MainLayout() { queryKey: ['auth', 'me'], queryFn: async () => { const res = await apiClient.get('/auth/me'); - return res.data as { username?: string }; + return res.data as { username?: string; tenant_name?: string; tenant_id?: string }; }, retry: false, }); @@ -183,9 +185,6 @@ export function MainLayout() { ); })} -
- -
@@ -235,11 +234,22 @@ export function MainLayout() { className={`absolute right-0 top-full pt-2 ${userMenuOpen ? 'block' : 'hidden'}`} ref={userMenuDropdownRef} > -
-
+
+
{me?.username || 'User'}
+ {me?.tenant_name && ( +
+ {me.tenant_name} +
+ )} +
+
+
+ Theme +
+
-
- -
diff --git a/hive-hq/ui/src/pages/ClusterDetailPage.tsx b/hive-hq/ui/src/pages/ClusterDetailPage.tsx index 2705092..36925cf 100644 --- a/hive-hq/ui/src/pages/ClusterDetailPage.tsx +++ b/hive-hq/ui/src/pages/ClusterDetailPage.tsx @@ -798,6 +798,7 @@ export function ClusterDetailPage() { namespaceId: editingNamespace.namespace_id, serviceDefinitionIds }); + setEditingNamespace(null); refetchNamespaces(); refetchReleases(); } catch (err) { @@ -812,7 +813,14 @@ export function ClusterDetailPage() { namespaceId: editingNamespace.namespace_id, serviceName }); - setEditingNamespace(null); + // Update the local editingNamespace state to reflect the removal + setEditingNamespace(prev => { + if (!prev) return null; + return { + ...prev, + service_names: prev.service_names.filter(name => name !== serviceName) + }; + }); refetchNamespaces(); refetchReleases(); } catch (err) { @@ -1248,6 +1256,68 @@ export function ClusterDetailPage() { )} + {/* Releases Section */} +
+
+
+

+ Releases ({releases.length}) +

+
+
+ r.data.id} + isLoading={loadingReleases} + emptyMessage="No releases found for this cluster" + searchPlaceholder="Search releases..." + isLoadingMore={loadingMoreReleases} + allLoaded={allReleasesLoaded} + totalItems={releases.length} + /> +
+ + {/* Namespaces Section */} +
+
+

+ Namespaces ({namespaces.length}) +

+
+ {namespaces.length === 0 ? ( +
+ +

No namespaces registered yet

+

+ Namespaces are automatically registered when the agent detects them in your cluster. +

+

To register a namespace:

+
    +
  1. Add the label beecd.io/enabled=true to your namespace
  2. +
  3. The agent will automatically discover and register it on its next sync
  4. +
+

+ Example: kubectl label namespace my-namespace beecd.io/enabled=true +

+
+
+ ) : ( + ns.namespace_id} + isLoading={loadingNamespaces} + emptyMessage="No namespaces found for this cluster" + searchPlaceholder="Search namespaces..." + isLoadingMore={loadingMoreNamespaces} + allLoaded={allNamespacesLoaded} + totalItems={namespaces.length} + onRowClick={(ns) => setEditingNamespace(ns)} + /> + )} +
+ {/* Cluster Groups */} {groups && groups.length > 0 && (
@@ -1288,58 +1358,6 @@ export function ClusterDetailPage() { />
- {/* Releases Section */} -
-
-
-

- Releases ({releases.length}) -

-
-
- r.data.id} - isLoading={loadingReleases} - emptyMessage="No releases found for this cluster" - searchPlaceholder="Search releases..." - isLoadingMore={loadingMoreReleases} - allLoaded={allReleasesLoaded} - totalItems={releases.length} - /> -
- - {/* Namespaces Section */} -
-
-
-

- Namespaces ({namespaces.length}) -

- -
-
- ns.namespace_id} - isLoading={loadingNamespaces} - emptyMessage="No namespaces found for this cluster" - searchPlaceholder="Search namespaces..." - isLoadingMore={loadingMoreNamespaces} - allLoaded={allNamespacesLoaded} - totalItems={namespaces.length} - onRowClick={(ns) => setEditingNamespace(ns)} - /> -
- {/* Edit Namespace Services Modal */} .beecd.localhost or .beecd.com etc + if (host.includes('.')) { + const parts = host.split('.'); + // If more than 2 parts (e.g., tenant.beecd.localhost), first part is tenant + if (parts.length >= 3 || (parts.length === 2 && parts[0] !== 'beecd')) { + return parts[0]; + } + } + return null; // Base domain (no tenant) +} + export function LoginPage() { const navigate = useNavigate(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [mode, setMode] = useState<'login' | 'bootstrap'>('login'); - const [bootstrapAvailable, setBootstrapAvailable] = useState(false); - const [username, setUsername] = useState(''); - const [password, setPassword] = useState(''); + const tenantSlug = getTenantFromHost(); + const isBaseDomain = tenantSlug === null; - useEffect(() => { - let cancelled = false; - (async () => { - try { - const res = await apiClient.get('/auth/bootstrap/status'); - const required = !!res.data?.bootstrap_required; - if (!cancelled) { - setBootstrapAvailable(required); - if (!required && mode === 'bootstrap') { - setMode('login'); - } - } - } catch { - // If status check fails, default to hiding bootstrap. It's safer. - if (!cancelled) { - setBootstrapAvailable(false); - if (mode === 'bootstrap') { - setMode('login'); - } - } - } - })(); + // Two-step flow for base domain: first ask for tenant name + const [step] = useState<'tenant' | 'credentials'>(isBaseDomain ? 'tenant' : 'credentials'); + const [tenantName, setTenantName] = useState(''); - return () => { - cancelled = true; - }; - // mode is intentionally included so we can force-switch back to login if needed. - }, [mode]); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); @@ -48,17 +38,29 @@ export function LoginPage() { setError(null); try { - const path = mode === 'bootstrap' ? '/auth/bootstrap' : '/auth/login'; - await apiClient.post(path, { + // Base domain two-step flow: redirect to tenant subdomain + if (isBaseDomain && step === 'tenant') { + const slug = tenantName.trim().toLowerCase().replace(/[^a-z0-9-]/g, ''); + if (!slug) { + setError('Please enter a valid tenant name'); + setLoading(false); + return; + } + // Redirect to tenant subdomain + const protocol = window.location.protocol; + const baseDomain = window.location.hostname; + const port = window.location.port ? `:${window.location.port}` : ''; + window.location.href = `${protocol}//${slug}.${baseDomain}${port}/login`; + return; + } + + // Actual login (either from tenant subdomain or after redirect) + await apiClient.post('/auth/login', { username: username.trim(), password, }); navigate('/'); } catch (err: any) { - if (mode === 'bootstrap' && err?.response?.status === 409) { - setBootstrapAvailable(false); - setMode('login'); - } const msg = err?.response?.data && typeof err.response.data === 'string' ? err.response.data @@ -93,59 +95,119 @@ export function LoginPage() {
-
-

- {mode === 'bootstrap' ? 'Initial Setup' : 'Sign in'} -

- {bootstrapAvailable && ( - - )} -
- -
-
- - setUsername(e.target.value)} - autoComplete="username" - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" - /> -
-
- - setPassword(e.target.value)} - autoComplete={mode === 'bootstrap' ? 'new-password' : 'current-password'} - className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" - /> -
- - -
+ {isBaseDomain && step === 'tenant' ? ( + // Step 1: Ask for tenant name at base domain + <> +

+ Enter your tenant +

+
+
+ + setTenantName(e.target.value)} + placeholder="your-company" + autoFocus + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+
+ +
+ + ) : ( + // Step 2: Username/password (either on tenant subdomain or after redirect) + <> +
+

+ Sign in +

+
+ +
+
+ + setUsername(e.target.value)} + autoComplete="username" + autoFocus + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ + +
+ + {!isBaseDomain && ( +
+ +
+ )} + + {isBaseDomain && ( +
+ +
+ )} + + )}
diff --git a/hive-hq/ui/src/pages/RegisterPage.tsx b/hive-hq/ui/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..2840298 --- /dev/null +++ b/hive-hq/ui/src/pages/RegisterPage.tsx @@ -0,0 +1,182 @@ +import { useState, type FormEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import apiClient from '@/lib/api-client'; +import { ThemeToggle } from '@/components'; + +export function RegisterPage() { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [tenantName, setTenantName] = useState(''); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + setSuccess(null); + + if (password !== confirmPassword) { + setError('Passwords do not match'); + setLoading(false); + return; + } + + if (!email.includes('@')) { + setError('Username must be a valid email address'); + setLoading(false); + return; + } + + try { + const res = await apiClient.post('/api/tenants/register', { + username: email.trim(), + password, + tenant_name: tenantName.trim(), + }); + + const domain = res.data?.domain; + if (domain) { + // Extract slug from domain (e.g., "test.beecd.local" -> "test") + const slug = domain.split('.')[0]; + setSuccess(`Tenant created! Redirecting to ${domain}...`); + + // Redirect to tenant subdomain after brief delay + setTimeout(() => { + const protocol = window.location.protocol; + const baseDomain = window.location.hostname; + const port = window.location.port ? `:${window.location.port}` : ''; + // Reconstruct with tenant subdomain + window.location.href = `${protocol}//${slug}.${baseDomain}${port}/`; + }, 2000); + } else { + navigate('/login'); + } + } catch (err: any) { + const msg = + err?.response?.data && typeof err.response.data === 'string' + ? err.response.data + : 'Registration failed'; + setError(msg); + console.error(err); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ +
+ +
+
+

🐝 BeeCD

+

+ Create Your Tenant +

+

+ Register your organization on BeeCD +

+
+ + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + +
+
+
+
+ + setTenantName(e.target.value)} + placeholder="e.g., Acme Inc" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +

+ Your organization's name +

+
+ +
+ + setEmail(e.target.value)} + placeholder="admin@example.com" + autoComplete="email" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + setPassword(e.target.value)} + autoComplete="new-password" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ +
+ + setConfirmPassword(e.target.value)} + autoComplete="new-password" + className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> +
+ + +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/hive-hq/ui/src/pages/ReposPage.tsx b/hive-hq/ui/src/pages/ReposPage.tsx index 62e211e..7a2b8a0 100644 --- a/hive-hq/ui/src/pages/ReposPage.tsx +++ b/hive-hq/ui/src/pages/ReposPage.tsx @@ -58,7 +58,8 @@ export function ReposPage() { const host = inferHost(newUrl); const needsProvider = !!host && host !== 'github.com'; - const handleAddRepo = async () => { + const handleAddRepo = async (e?: React.FormEvent) => { + e?.preventDefault(); if (!newUrl.trim()) return; if (needsProvider && !selectedProvider) return; @@ -186,7 +187,7 @@ export function ReposPage() { : 'Add a GitHub repository to track for deployments.'}

-
+
@@ -220,7 +222,7 @@ export function ReposPage() {
)} - + {createRepoMutation.isError && (
@@ -234,12 +236,14 @@ export function ReposPage() {
+ )} +
+ + {hasGithubToken ? ( +
+ ✓ Configured (created:{' '} + {new Date( + secrets?.find((s) => s.purpose === 'github_token')?.created_at || '' + ).toLocaleString()} + ) +
+ ) : ( +
+ setGithubToken(e.target.value)} + placeholder="ghp_xxxxxxxxxxxx" + className="w-full px-3 py-2 mb-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-blue-500 focus:border-blue-500" + /> + +
+ )} +
+ + {/* PGP Key */} +
+
+

+ PGP Private Key (SOPS) +

+ {hasPgpKey && ( + + )} +
+ + {hasPgpKey ? ( +
+ ✓ Configured (created:{' '} + {new Date( + secrets?.find((s) => s.purpose === 'pgp_private_key')?.created_at || '' + ).toLocaleString()} + ) +
+ ) : ( +
+