diff --git a/.github/k8s/sam-control-plane-template.yaml b/.github/k8s/sam-control-plane-template.yaml index 53700c1b..d81247f6 100644 --- a/.github/k8s/sam-control-plane-template.yaml +++ b/.github/k8s/sam-control-plane-template.yaml @@ -123,7 +123,6 @@ spec: - "--db-dsn=postgres://sam:sam-secret-password@sam-db-${ENV_NAME}:5432/sam_mesh?sslmode=disable" - "--issuer=https://auth.sam-mesh.dev,https://container.googleapis.com/v1/projects/${GCP_PROJECT_ID}/locations/${CLUSTER_REGION}/clusters/${CLUSTER_NAME}" - "--allowed-audiences=sam-mesh-audience,sam-hub-audience" - - "--policy-file=/etc/sam/policies/policies.yaml" - "--admin-token=$(ADMIN_TOKEN)" - "--auto-approve-enrollment" resources: @@ -133,53 +132,6 @@ spec: limits: cpu: 500m memory: 512Mi - volumeMounts: - - name: policies-volume - mountPath: /etc/sam/policies - readOnly: true - volumes: - - name: policies-volume - configMap: - name: sam-control-plane-policies-${ENV_NAME} ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sam-control-plane-policies-${ENV_NAME} - namespace: ${NAMESPACE} -data: - policies.yaml: | - version: "v1alpha1" - bindings: - - members: ["user:system:serviceaccount:sam-canary-${ENV_NAME}:sam-node-sa"] - role: "sam-canary" - - members: ["user:system:serviceaccount:sam-canary-${ENV_NAME}:sam-box-sa"] - role: "sam:role:sambox" - - members: ["group:routers", "user:system:serviceaccount:${NAMESPACE}:sam-router-sa"] - role: "sam:role:router" - - members: ["sam:system:authenticated"] - role: "public-mesh" - roles: - sam-canary: - allowed_services: - - "*" - allowed_targets: - - "*" - "sam:role:sambox": - allowed_services: - - "*" - allowed_targets: - - "*" - "sam:role:router": - allowed_services: - - "*" - allowed_targets: - - "*" - public-mesh: - allowed_services: - - "*" - allowed_targets: - - "*" --- apiVersion: networking.gke.io/v1 kind: HealthCheckPolicy diff --git a/.github/workflows/deploy-github-pages.yml b/.github/workflows/deploy-github-pages.yml index b476f53c..64d81a64 100644 --- a/.github/workflows/deploy-github-pages.yml +++ b/.github/workflows/deploy-github-pages.yml @@ -17,8 +17,6 @@ on: permissions: contents: read - pages: write - id-token: write env: REPO_NAME: ${{ github.event.repository.name }} @@ -28,18 +26,19 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod submodules: recursive # Fetch any git submodules if configured + persist-credentials: false - name: Setup Hugo - uses: peaceiris/actions-hugo@v3 + uses: peaceiris/actions-hugo@75d2e84710de30f6ff7268e08f310b60ef14033f # v3.0.0 with: hugo-version: '0.136.5' # Matching local Hugo version extended: true - - uses: actions/setup-node@v7 + - uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0 with: node-version: '20' check-latest: true @@ -58,13 +57,17 @@ jobs: run: echo "sam-mesh.dev" > ./site/public/CNAME - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 with: path: ./site/public deploy: needs: build if: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' }} + permissions: + contents: read + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -74,4 +77,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index adb229d5..e3de06fa 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -22,16 +22,18 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to the Container registry - uses: docker/login-action@v4 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -39,7 +41,7 @@ jobs: - name: Extract metadata for Control Plane id: meta-cp - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-control-plane tags: | @@ -50,7 +52,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push Control Plane image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.sam-control-plane @@ -61,7 +63,7 @@ jobs: - name: Extract metadata for Router id: meta-router - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-router tags: | @@ -72,7 +74,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push Router image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.sam-router @@ -83,7 +85,7 @@ jobs: - name: Extract metadata for Node id: meta-node - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-node tags: | @@ -94,7 +96,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push Node image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.sam-node @@ -105,7 +107,7 @@ jobs: - name: Extract metadata for nano-init id: meta-nano-init - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-nano-init tags: | @@ -116,7 +118,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push nano-init image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.nano-init @@ -127,7 +129,7 @@ jobs: - name: Extract metadata for sam-box id: meta-sambox - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-box tags: | @@ -138,7 +140,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push sam-box image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.sam-box @@ -149,7 +151,7 @@ jobs: - name: Extract metadata for sam-console id: meta-console - uses: docker/metadata-action@v6 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ${{ env.REGISTRY }}/google/sam-console tags: | @@ -160,7 +162,7 @@ jobs: type=raw,value=stable,enable=${{ startsWith(github.ref, 'refs/tags/v') }} - name: Build and push sam-console image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . file: Dockerfile.sam-console @@ -181,31 +183,39 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Determine Image Tag + env: + REF_TYPE: ${{ github.ref_type }} + REF_NAME: ${{ github.ref_name }} + COMMIT_SHA: ${{ github.sha }} run: | - if [[ "${{ github.ref_type }}" == "tag" ]]; then - echo "IMAGE_TAG=${{ github.ref_name }}" >> $GITHUB_ENV + if [[ "${REF_TYPE}" == "tag" ]]; then + echo "IMAGE_TAG=${REF_NAME}" >> $GITHUB_ENV else - echo "IMAGE_TAG=${{ github.sha }}" >> $GITHUB_ENV + echo "IMAGE_TAG=${COMMIT_SHA}" >> $GITHUB_ENV fi - name: Google Auth - uses: google-github-actions/auth@v3 + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2 with: workload_identity_provider: ${{ vars.WIF_PROVIDER_NAME }} service_account: ${{ vars.SERVICE_ACCOUNT_EMAIL }} - name: Set up GKE credentials - uses: google-github-actions/get-gke-credentials@v3 + uses: google-github-actions/get-gke-credentials@64bc7249bbcf78056bb92f14d3cedc2da193946c # v2 with: cluster_name: ${{ vars.CLUSTER_NAME }} location: ${{ vars.CLUSTER_REGION }} - name: Prepare Namespaces + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} run: | - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" export CANARY_NAMESPACE="sam-canary-${ENV_NAME}" @@ -272,8 +282,10 @@ jobs: } - name: Provision Hub Secrets + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} run: | - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" # Idempotently provision the sam-hub-secret if it doesn't already exist if ! kubectl get secret sam-hub-secret -n ${NAMESPACE} >/dev/null 2>&1; then @@ -286,11 +298,11 @@ jobs: echo "sam-hub-secret already exists in namespace ${NAMESPACE}. Skipping generation." fi - - - name: Provision Control Plane Secrets + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} run: | - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" if ! kubectl get secret sam-control-plane-secret-${ENV_NAME} -n ${NAMESPACE} >/dev/null 2>&1; then echo "Generating a new random 32-byte hex key for admin-token..." @@ -303,6 +315,12 @@ jobs: fi - name: Deploy Control Plane and Routers + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} + VAR_IMAGE_TAG: ${{ env.IMAGE_TAG }} + VAR_GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + VAR_CLUSTER_REGION: ${{ vars.CLUSTER_REGION }} + VAR_CLUSTER_NAME: ${{ vars.CLUSTER_NAME }} run: | print_rollout_diagnostics() { local namespace="$1" @@ -329,12 +347,12 @@ jobs: sudo apt-get install -y gettext-base - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" - export IMAGE_TAG="${{ env.IMAGE_TAG }}" - export GCP_PROJECT_ID="${{ vars.GCP_PROJECT_ID }}" - export CLUSTER_REGION="${{ vars.CLUSTER_REGION }}" - export CLUSTER_NAME="${{ vars.CLUSTER_NAME }}" + export IMAGE_TAG="${VAR_IMAGE_TAG}" + export GCP_PROJECT_ID="${VAR_GCP_PROJECT_ID}" + export CLUSTER_REGION="${VAR_CLUSTER_REGION}" + export CLUSTER_NAME="${VAR_CLUSTER_NAME}" envsubst '${ENV_NAME} ${NAMESPACE} ${GCP_PROJECT_ID} ${CLUSTER_NAME} ${CLUSTER_REGION} ${IMAGE_TAG}' < .github/k8s/sam-control-plane-template.yaml | kubectl apply -f - envsubst '${ENV_NAME} ${NAMESPACE} ${GCP_PROJECT_ID} ${CLUSTER_NAME} ${CLUSTER_REGION} ${IMAGE_TAG}' < .github/k8s/sam-router-template.yaml | kubectl apply -f - @@ -347,6 +365,41 @@ jobs: exit 1 } + echo "Seeding Control Plane Policies..." + ADMIN_TOKEN=$(kubectl get secret sam-control-plane-secret-${ENV_NAME} -n ${NAMESPACE} -o jsonpath='{.data.admin-token}' | base64 -d) + + kubectl port-forward deployment/sam-control-plane-${ENV_NAME} -n ${NAMESPACE} 8080:8080 & + PF_PID=$! + + for i in {1..15}; do + if nc -z localhost 8080; then + break + fi + sleep 1 + done + + curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -d '{ + "roles": [ + {"name": "sam-canary", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:sambox", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "public-mesh", "allowed_services": ["*"], "allowed_targets": ["*"]} + ], + "bindings": [ + {"role": "sam-canary", "members": ["user:system:serviceaccount:sam-canary-'"${ENV_NAME}"':sam-node-sa"]}, + {"role": "sam:role:sambox", "members": ["user:system:serviceaccount:sam-canary-'"${ENV_NAME}"':sam-box-sa"]}, + {"role": "sam:role:router", "members": ["group:routers", "user:system:serviceaccount:'"${NAMESPACE}"':sam-router-sa"]}, + {"role": "public-mesh", "members": ["sam:system:authenticated"]} + ] + }' \ + http://localhost:8080/policies >/dev/null + + kill $PF_PID + + kubectl rollout status deployment/sam-console-${ENV_NAME} -n ${NAMESPACE} --timeout=120s || { echo "Console Deployment failed!" print_rollout_diagnostics "${NAMESPACE}" "deployment/sam-console-${ENV_NAME}" "app=sam-console-${ENV_NAME}" @@ -376,12 +429,17 @@ jobs: - name: Deploy GCE VM Router + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} + VAR_IMAGE_TAG: ${{ env.IMAGE_TAG }} + VAR_GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID }} + VAR_CLUSTER_REGION: ${{ vars.CLUSTER_REGION }} run: | - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" - export IMAGE_TAG="${{ env.IMAGE_TAG }}" - export GCP_PROJECT_ID="${{ vars.GCP_PROJECT_ID }}" - export CLUSTER_REGION="${{ vars.CLUSTER_REGION }}" + export IMAGE_TAG="${VAR_IMAGE_TAG}" + export GCP_PROJECT_ID="${VAR_GCP_PROJECT_ID}" + export CLUSTER_REGION="${VAR_CLUSTER_REGION}" export ZONE="${CLUSTER_REGION}-a" echo "Retrieving Admin Token to generate Bootstrap Token..." @@ -454,6 +512,11 @@ jobs: - name: Deploy Canaries + env: + VAR_ENV_NAME: ${{ vars.ENV_NAME }} + VAR_IMAGE_TAG: ${{ env.IMAGE_TAG }} + VAR_GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + VAR_HF_TOKEN: ${{ secrets.HF_TOKEN }} run: | print_rollout_diagnostics() { local namespace="$1" @@ -478,12 +541,12 @@ jobs: done } - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export CANARY_NAMESPACE="sam-canary-${ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" - export IMAGE_TAG="${{ env.IMAGE_TAG }}" - export GEMINI_API_KEY="${{ secrets.GEMINI_API_KEY }}" - export HF_TOKEN="${{ secrets.HF_TOKEN }}" + export IMAGE_TAG="${VAR_IMAGE_TAG}" + export GEMINI_API_KEY="${VAR_GEMINI_API_KEY}" + export HF_TOKEN="${VAR_HF_TOKEN}" # Read and indent banana bot playground script for the ConfigMap template export BANANA_BOT_SCRIPT=$(cat site/content/docs/snippets/banana_bot_playground.py | sed 's/^/ /') @@ -563,6 +626,8 @@ jobs: - name: Deploy OpenRouter Node to Testnet env: OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + VAR_ENV_NAME: ${{ vars.ENV_NAME }} + VAR_IMAGE_TAG: ${{ env.IMAGE_TAG }} run: | print_rollout_diagnostics() { local namespace="$1" @@ -587,10 +652,10 @@ jobs: done } - export ENV_NAME="${{ vars.ENV_NAME }}" + export ENV_NAME="${VAR_ENV_NAME}" export NAMESPACE="sam-${ENV_NAME}" export CANARY_NAMESPACE="sam-canary-${ENV_NAME}" - export IMAGE_TAG="${{ env.IMAGE_TAG }}" + export IMAGE_TAG="${VAR_IMAGE_TAG}" # Substitute the API key and other variables into the template and apply it to the cluster envsubst < .github/k8s/sam-node-openrouter-template.yaml | kubectl apply -f - diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bb96e36d..43cb8180 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,6 +24,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + env: GO_VERSION: "1.26" @@ -33,11 +36,16 @@ jobs: name: Bats e2e tests steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + cache: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: make build - name: Setup Bats and bats libs id: setup-bats @@ -52,7 +60,7 @@ jobs: - name: Upload logs if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: e2e-logs-${{ env.JOB_NAME }}-${{ github.run_id }} path: ./_artifacts diff --git a/.github/workflows/kind-mesh-e2e.yml b/.github/workflows/kind-mesh-e2e.yml index 9f09440d..496c52df 100644 --- a/.github/workflows/kind-mesh-e2e.yml +++ b/.github/workflows/kind-mesh-e2e.yml @@ -24,6 +24,9 @@ on: branches: [main] workflow_dispatch: +permissions: + contents: read + env: GO_VERSION: "1.26" @@ -33,15 +36,20 @@ jobs: name: Kind mesh e2e steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Setup Go - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} + cache: false - name: Install kind and kubectl run: | + sudo sysctl fs.inotify.max_user_watches=524288 || true + sudo sysctl fs.inotify.max_user_instances=512 || true go install sigs.k8s.io/kind@latest echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" curl -sSLo /tmp/kubectl "https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" @@ -62,17 +70,12 @@ jobs: if: failure() run: | mkdir -p _artifacts - kubectl --context kind-sam-kind -n sam-kind get pods -o wide > _artifacts/pods.txt 2>&1 || true - kubectl --context kind-sam-kind -n sam-kind logs deploy/sam-control-plane --all-containers --tail=-1 > _artifacts/sam-control-plane.log 2>&1 || true - kubectl --context kind-sam-kind -n sam-kind logs statefulset/sam-router --all-containers --tail=-1 > _artifacts/sam-router.log 2>&1 || true - for d in node-a node-b node-c; do - kubectl --context kind-sam-kind -n sam-kind logs "deploy/$d" --all-containers --tail=-1 > "_artifacts/$d.log" 2>&1 || true - done + kind export logs --name sam-kind ./_artifacts/logs cp "$RUNNER_TEMP/local-node.log" _artifacts/ 2>/dev/null || true - name: Upload logs if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: kind-mesh-e2e-logs-${{ github.run_id }} path: ./_artifacts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7df4b3e6..4d12d183 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,26 +15,28 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: stable + cache: false - name: Set up Java - uses: actions/setup-java@v5 + uses: actions/setup-java@0f481fcb613427c0f801b606911222b5b6f3083a # v5.5.0 with: distribution: 'temurin' java-version: '17' - name: Set up Flutter - uses: subosito/flutter-action@v2 + uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: 'stable' - name: Build Mobile App run: make mobile-app-apk - name: Run GoReleaser - uses: goreleaser/goreleaser-action@v7 + uses: goreleaser/goreleaser-action@90a3faa9d0182683851fbfa97ca1a2cb983bfca3 # v6.2.1 with: distribution: goreleaser version: latest @@ -42,7 +44,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload Mobile App to Release - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@d5382d3e6f2fa7bd53cb749d33091853d4985daf # v2.3.0 with: files: mobile/sam-node-app/build/app/outputs/flutter-apk/app-release.apk env: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4d3565f4..166f6680 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -22,6 +22,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + env: GO_VERSION: "1.26" @@ -29,28 +32,46 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + cache: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: sudo make test lint: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + cache: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: make lint verify: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + cache: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Install protoc run: sudo apt-get update && sudo apt-get install -y protobuf-compiler - run: make verify + + helm-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 # v4.3.0 + - run: make helm-lint diff --git a/.github/workflows/test_flutter.yaml b/.github/workflows/test_flutter.yaml index c077306a..6c8fae3a 100644 --- a/.github/workflows/test_flutter.yaml +++ b/.github/workflows/test_flutter.yaml @@ -22,6 +22,9 @@ on: pull_request: branches: [ main ] +permissions: + contents: read + env: GO_VERSION: "1.26" @@ -29,16 +32,21 @@ jobs: build-ffi: runs-on: ubuntu-latest steps: - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + cache: false + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - run: make mobile-ffi-host analyze-flutter: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: 'stable' @@ -52,10 +60,13 @@ jobs: e2e-android: runs-on: ubuntu-22.04-16core steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6 - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: ${{ env.GO_VERSION }} + cache: false - uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: 'stable' diff --git a/.github/workflows/test_python.yml b/.github/workflows/test_python.yml index 23ece284..a4a6249e 100644 --- a/.github/workflows/test_python.yml +++ b/.github/workflows/test_python.yml @@ -10,20 +10,25 @@ on: paths: - 'sam-mcp-python/**' +permissions: + contents: read + jobs: test_python: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: '1.22' - name: Set up Python - uses: actions/setup-python@v7 + uses: actions/setup-python@42375524e23c412d93fb67b49958b491fce71c38 # v5.4.0 with: python-version: '3.10' diff --git a/Makefile b/Makefile index 66e9fd18..422a3e85 100644 --- a/Makefile +++ b/Makefile @@ -152,7 +152,20 @@ fmt: go fmt ./... # code linters -lint: fmt +.PHONY: helm-lint +helm-lint: + @HELM_BIN="helm"; \ + if ! command -v helm >/dev/null 2>&1; then \ + if [ -x "./bin/helm" ]; then \ + HELM_BIN="./bin/helm"; \ + else \ + echo "helm not found; please install helm or place it in ./bin/helm" >&2; \ + exit 1; \ + fi; \ + fi; \ + $$HELM_BIN lint ./charts/sam-mesh + +lint: fmt helm-lint hack/lint.sh .PHONY: verify diff --git a/api/datalog.go b/api/datalog.go index 47322f78..c12303b7 100644 --- a/api/datalog.go +++ b/api/datalog.go @@ -17,6 +17,7 @@ package api import ( "fmt" "maps" + "strings" "github.com/biscuit-auth/biscuit-go/v2" "github.com/biscuit-auth/biscuit-go/v2/parser" @@ -193,6 +194,7 @@ var oidcClaimToFact = map[string]string{ "sub": FactUser, "email": FactEmail, "groups": FactGroup, + "roles": FactRole, } // OIDCClaimToFact returns a copy of the OIDC claims to Biscuit facts map. @@ -332,3 +334,66 @@ func init() { panic(fmt.Sprintf("failed to parse static allow policy: %v", err)) } } + +// BuildServiceDatalogFact translates a service pattern string into a Datalog Fact. +func BuildServiceDatalogFact(serviceStr string) biscuit.Fact { + svcType, svcName := ParseServiceTarget(serviceStr) + if svcType == "*" && svcName == "*" { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedServiceAllTypes, + IDs: []biscuit.Term{}, + }} + } else if svcName == "*" { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedServiceAll, + IDs: []biscuit.Term{biscuit.String(svcType)}, + }} + } else if strings.HasPrefix(svcName, "*.") { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedServiceSuffix, + IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName[1:])}, + }} + } else if strings.HasSuffix(svcName, ".*") { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedServicePrefix, + IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName[:len(svcName)-1])}, + }} + } + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedServiceExact, + IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName)}, + }} +} + +// BuildTargetDatalogFact translates a target pattern string into a Datalog Fact. +func BuildTargetDatalogFact(targetStr string) biscuit.Fact { + tFact, tVal := ParseServiceTarget(targetStr) + if tFact == "" { + tFact = "node" + } + if tFact == "*" && tVal == "*" { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedTargetAllFacts, + IDs: []biscuit.Term{}, + }} + } else if tVal == "*" { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedTargetAll, + IDs: []biscuit.Term{biscuit.String(tFact)}, + }} + } else if strings.HasPrefix(tVal, "*.") { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedTargetSuffix, + IDs: []biscuit.Term{biscuit.String(tFact), biscuit.String(tVal[1:])}, + }} + } else if strings.HasSuffix(tVal, ".*") { + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedTargetPrefix, + IDs: []biscuit.Term{biscuit.String(tFact), biscuit.String(tVal[:len(tVal)-1])}, + }} + } + return biscuit.Fact{Predicate: biscuit.Predicate{ + Name: FactGrantedTargetExact, + IDs: []biscuit.Term{biscuit.String(tFact), biscuit.String(tVal)}, + }} +} diff --git a/api/datalog_test.go b/api/datalog_test.go index 7d3bb3bd..c7366d34 100644 --- a/api/datalog_test.go +++ b/api/datalog_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/datalog" ) // helper to generate keypair for tests @@ -130,7 +131,7 @@ func TestBaselinePolicies(t *testing.T) { t.Fatalf("failed to build token: %v", err) } - authorizer, err := tok.Authorizer(pub) + authorizer, err := tok.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(5*time.Second))) if err != nil { t.Fatalf("failed to create authorizer: %v", err) } @@ -192,7 +193,10 @@ func TestBaselineReplayCheck(t *testing.T) { }}) tok, _ := builder.Build() - authorizer, _ := tok.Authorizer(pub) + authorizer, err := tok.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(5*time.Second))) + if err != nil { + t.Fatalf("failed to create authorizer: %v", err) + } // connection_peer_id is injected as a runtime connection fact authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ @@ -204,7 +208,7 @@ func TestBaselineReplayCheck(t *testing.T) { authorizer.AddCheck(BaselineReplayCheck) authorizer.AddPolicy(AllowIfTruePolicy) - err := authorizer.Authorize() + err = authorizer.Authorize() if tt.expectAllow && err != nil { t.Errorf("expected authorized, got error: %v", err) } else if !tt.expectAllow && err == nil { @@ -263,7 +267,10 @@ func TestBaselineTargetCheck(t *testing.T) { } tok, _ := builder.Build() - authorizer, _ := tok.Authorizer(pub) + authorizer, err := tok.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(5*time.Second))) + if err != nil { + t.Fatalf("failed to create authorizer: %v", err) + } // target_fact is evaluated at runtime (e.g. node(...) or group(...)) authorizer.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ @@ -282,7 +289,7 @@ func TestBaselineTargetCheck(t *testing.T) { } authorizer.AddPolicy(AllowIfTruePolicy) - err := authorizer.Authorize() + err = authorizer.Authorize() if tt.expectAllow && err != nil { t.Errorf("expected authorized, got error: %v", err) } else if !tt.expectAllow && err == nil { @@ -351,6 +358,7 @@ func TestOIDCClaimToFact(t *testing.T) { "sub": FactUser, "email": FactEmail, "groups": FactGroup, + "roles": FactRole, } if !reflect.DeepEqual(facts, want) { @@ -365,3 +373,73 @@ func TestOIDCClaimToFact(t *testing.T) { t.Errorf("OIDCClaimToFact() returned map is not a clone, modifications affect internal state") } } + +func TestBuildServiceDatalogFact(t *testing.T) { + tests := []struct { + input string + expected biscuit.Fact + }{ + { + input: "*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServiceAllTypes, IDs: []biscuit.Term{}}}, + }, + { + input: "mcp://*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServiceAll, IDs: []biscuit.Term{biscuit.String("mcp")}}}, + }, + { + input: "mcp://*.local", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServiceSuffix, IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String(".local")}}}, + }, + { + input: "mcp://calc.*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServicePrefix, IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("calc.")}}}, + }, + { + input: "mcp://calculator", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("calculator")}}}, + }, + } + + for _, tt := range tests { + got := BuildServiceDatalogFact(tt.input) + if got.String() != tt.expected.String() { + t.Errorf("BuildServiceDatalogFact(%q) = %s, want %s", tt.input, got.String(), tt.expected.String()) + } + } +} + +func TestBuildTargetDatalogFact(t *testing.T) { + tests := []struct { + input string + expected biscuit.Fact + }{ + { + input: "*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedTargetAllFacts, IDs: []biscuit.Term{}}}, + }, + { + input: "user:*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedTargetAll, IDs: []biscuit.Term{biscuit.String("user")}}}, + }, + { + input: "group:*.internal", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedTargetSuffix, IDs: []biscuit.Term{biscuit.String("group"), biscuit.String(".internal")}}}, + }, + { + input: "group:backend.*", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedTargetPrefix, IDs: []biscuit.Term{biscuit.String("group"), biscuit.String("backend.")}}}, + }, + { + input: "group:backend", + expected: biscuit.Fact{Predicate: biscuit.Predicate{Name: FactGrantedTargetExact, IDs: []biscuit.Term{biscuit.String("group"), biscuit.String("backend")}}}, + }, + } + + for _, tt := range tests { + got := BuildTargetDatalogFact(tt.input) + if got.String() != tt.expected.String() { + t.Errorf("BuildTargetDatalogFact(%q) = %s, want %s", tt.input, got.String(), tt.expected.String()) + } + } +} diff --git a/api/policy.go b/api/policy.go index 11a5a505..f11f1543 100644 --- a/api/policy.go +++ b/api/policy.go @@ -29,42 +29,6 @@ var ( } ) -// PolicyConfig is the root authorization configuration for the SAM Hub. -type PolicyConfig struct { - Version string `yaml:"version"` - Bindings []Binding `yaml:"bindings"` - Roles map[string]RolePolicy `yaml:"roles"` -} - -type Binding struct { - Role string `yaml:"role"` - Members []string `yaml:"members"` // format: "type:value" e.g. "user:alice", "group:eng" -} - -// RolePolicy defines the capabilities granted to a specific authorization role. -// -// AllowedTargets restricts the logical endpoints a peer can route connections to. -// Targets act similarly to Active Directory network groups and must be specified -// using the format of the resolved Biscuit facts. IP address ranges are NOT allowed. -// Valid examples: -// - "group:backend-nodes" -// - "user:admin@example.com" -// - "role:developer" -// - "node:12D3KooW..." -// -// AllowedServices defines the application-level services a peer can invoke. -// Services are prefixed by their protocol/type to permit fine-grained scoping. -// Wildcards are supported (e.g., "mcp:*"). -// Valid examples: -// - "mcp:local-shell-tools" -// - "inference:openrouter" -// - "system:query_db" -type RolePolicy struct { - AllowedTargets []string `yaml:"allowed_targets,omitempty"` - AllowedServices []string `yaml:"allowed_services,omitempty"` - CustomDatalog []string `yaml:"custom_datalog,omitempty"` -} - type ServiceConfig struct { Type string `yaml:"type"` // e.g., "mcp", "inference" Name string `yaml:"name"` diff --git a/api/sam.pb.go b/api/sam.pb.go index 4e7dc300..ecd03c30 100644 --- a/api/sam.pb.go +++ b/api/sam.pb.go @@ -139,8 +139,9 @@ func (ServiceType) EnumDescriptor() ([]byte, []int) { type MeshEvent_Type int32 const ( - MeshEvent_BANNED MeshEvent_Type = 0 - MeshEvent_KEY_ROTATION MeshEvent_Type = 1 + MeshEvent_BANNED MeshEvent_Type = 0 + MeshEvent_KEY_ROTATION MeshEvent_Type = 1 + MeshEvent_POLICY_UPDATE MeshEvent_Type = 2 ) // Enum value maps for MeshEvent_Type. @@ -148,10 +149,12 @@ var ( MeshEvent_Type_name = map[int32]string{ 0: "BANNED", 1: "KEY_ROTATION", + 2: "POLICY_UPDATE", } MeshEvent_Type_value = map[string]int32{ - "BANNED": 0, - "KEY_ROTATION": 1, + "BANNED": 0, + "KEY_ROTATION": 1, + "POLICY_UPDATE": 2, } ) @@ -1192,6 +1195,126 @@ func (x *RouterLeaseResponse) GetExpiresAt() int64 { return 0 } +type PolicyRole struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + AllowedTargets []string `protobuf:"bytes,2,rep,name=allowed_targets,json=allowedTargets,proto3" json:"allowed_targets,omitempty"` + AllowedServices []string `protobuf:"bytes,3,rep,name=allowed_services,json=allowedServices,proto3" json:"allowed_services,omitempty"` + CustomDatalog []string `protobuf:"bytes,4,rep,name=custom_datalog,json=customDatalog,proto3" json:"custom_datalog,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyRole) Reset() { + *x = PolicyRole{} + mi := &file_api_sam_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyRole) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyRole) ProtoMessage() {} + +func (x *PolicyRole) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyRole.ProtoReflect.Descriptor instead. +func (*PolicyRole) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{15} +} + +func (x *PolicyRole) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *PolicyRole) GetAllowedTargets() []string { + if x != nil { + return x.AllowedTargets + } + return nil +} + +func (x *PolicyRole) GetAllowedServices() []string { + if x != nil { + return x.AllowedServices + } + return nil +} + +func (x *PolicyRole) GetCustomDatalog() []string { + if x != nil { + return x.CustomDatalog + } + return nil +} + +type PolicyBinding struct { + state protoimpl.MessageState `protogen:"open.v1"` + Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` + Members []string `protobuf:"bytes,2,rep,name=members,proto3" json:"members,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PolicyBinding) Reset() { + *x = PolicyBinding{} + mi := &file_api_sam_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PolicyBinding) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PolicyBinding) ProtoMessage() {} + +func (x *PolicyBinding) ProtoReflect() protoreflect.Message { + mi := &file_api_sam_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PolicyBinding.ProtoReflect.Descriptor instead. +func (*PolicyBinding) Descriptor() ([]byte, []int) { + return file_api_sam_proto_rawDescGZIP(), []int{16} +} + +func (x *PolicyBinding) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + +func (x *PolicyBinding) GetMembers() []string { + if x != nil { + return x.Members + } + return nil +} + type PolicyConfigGetRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -1200,7 +1323,7 @@ type PolicyConfigGetRequest struct { func (x *PolicyConfigGetRequest) Reset() { *x = PolicyConfigGetRequest{} - mi := &file_api_sam_proto_msgTypes[15] + mi := &file_api_sam_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1212,7 +1335,7 @@ func (x *PolicyConfigGetRequest) String() string { func (*PolicyConfigGetRequest) ProtoMessage() {} func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[15] + mi := &file_api_sam_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1225,19 +1348,20 @@ func (x *PolicyConfigGetRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetRequest.ProtoReflect.Descriptor instead. func (*PolicyConfigGetRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{15} + return file_api_sam_proto_rawDescGZIP(), []int{17} } type PolicyConfigGetResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - YamlContent string `protobuf:"bytes,1,opt,name=yaml_content,json=yamlContent,proto3" json:"yaml_content,omitempty"` + Roles []*PolicyRole `protobuf:"bytes,1,rep,name=roles,proto3" json:"roles,omitempty"` + Bindings []*PolicyBinding `protobuf:"bytes,2,rep,name=bindings,proto3" json:"bindings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PolicyConfigGetResponse) Reset() { *x = PolicyConfigGetResponse{} - mi := &file_api_sam_proto_msgTypes[16] + mi := &file_api_sam_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1249,7 +1373,7 @@ func (x *PolicyConfigGetResponse) String() string { func (*PolicyConfigGetResponse) ProtoMessage() {} func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[16] + mi := &file_api_sam_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1262,26 +1386,34 @@ func (x *PolicyConfigGetResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigGetResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigGetResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{16} + return file_api_sam_proto_rawDescGZIP(), []int{18} } -func (x *PolicyConfigGetResponse) GetYamlContent() string { +func (x *PolicyConfigGetResponse) GetRoles() []*PolicyRole { if x != nil { - return x.YamlContent + return x.Roles } - return "" + return nil +} + +func (x *PolicyConfigGetResponse) GetBindings() []*PolicyBinding { + if x != nil { + return x.Bindings + } + return nil } type PolicyConfigUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - YamlContent string `protobuf:"bytes,1,opt,name=yaml_content,json=yamlContent,proto3" json:"yaml_content,omitempty"` + Roles []*PolicyRole `protobuf:"bytes,1,rep,name=roles,proto3" json:"roles,omitempty"` + Bindings []*PolicyBinding `protobuf:"bytes,2,rep,name=bindings,proto3" json:"bindings,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *PolicyConfigUpdateRequest) Reset() { *x = PolicyConfigUpdateRequest{} - mi := &file_api_sam_proto_msgTypes[17] + mi := &file_api_sam_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1293,7 +1425,7 @@ func (x *PolicyConfigUpdateRequest) String() string { func (*PolicyConfigUpdateRequest) ProtoMessage() {} func (x *PolicyConfigUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[17] + mi := &file_api_sam_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1306,14 +1438,21 @@ func (x *PolicyConfigUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigUpdateRequest.ProtoReflect.Descriptor instead. func (*PolicyConfigUpdateRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{17} + return file_api_sam_proto_rawDescGZIP(), []int{19} } -func (x *PolicyConfigUpdateRequest) GetYamlContent() string { +func (x *PolicyConfigUpdateRequest) GetRoles() []*PolicyRole { if x != nil { - return x.YamlContent + return x.Roles } - return "" + return nil +} + +func (x *PolicyConfigUpdateRequest) GetBindings() []*PolicyBinding { + if x != nil { + return x.Bindings + } + return nil } type PolicyConfigUpdateResponse struct { @@ -1326,7 +1465,7 @@ type PolicyConfigUpdateResponse struct { func (x *PolicyConfigUpdateResponse) Reset() { *x = PolicyConfigUpdateResponse{} - mi := &file_api_sam_proto_msgTypes[18] + mi := &file_api_sam_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1338,7 +1477,7 @@ func (x *PolicyConfigUpdateResponse) String() string { func (*PolicyConfigUpdateResponse) ProtoMessage() {} func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[18] + mi := &file_api_sam_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1351,7 +1490,7 @@ func (x *PolicyConfigUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use PolicyConfigUpdateResponse.ProtoReflect.Descriptor instead. func (*PolicyConfigUpdateResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{18} + return file_api_sam_proto_rawDescGZIP(), []int{20} } func (x *PolicyConfigUpdateResponse) GetSuccess() bool { @@ -1377,7 +1516,7 @@ type KeysResponse struct { func (x *KeysResponse) Reset() { *x = KeysResponse{} - mi := &file_api_sam_proto_msgTypes[19] + mi := &file_api_sam_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1389,7 +1528,7 @@ func (x *KeysResponse) String() string { func (*KeysResponse) ProtoMessage() {} func (x *KeysResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[19] + mi := &file_api_sam_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1402,7 +1541,7 @@ func (x *KeysResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KeysResponse.ProtoReflect.Descriptor instead. func (*KeysResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{19} + return file_api_sam_proto_rawDescGZIP(), []int{21} } func (x *KeysResponse) GetPublicKeys() [][]byte { @@ -1422,7 +1561,7 @@ type TokenRefreshRequest struct { func (x *TokenRefreshRequest) Reset() { *x = TokenRefreshRequest{} - mi := &file_api_sam_proto_msgTypes[20] + mi := &file_api_sam_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1434,7 +1573,7 @@ func (x *TokenRefreshRequest) String() string { func (*TokenRefreshRequest) ProtoMessage() {} func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[20] + mi := &file_api_sam_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1447,7 +1586,7 @@ func (x *TokenRefreshRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshRequest.ProtoReflect.Descriptor instead. func (*TokenRefreshRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{20} + return file_api_sam_proto_rawDescGZIP(), []int{22} } func (x *TokenRefreshRequest) GetChallengeSignature() []byte { @@ -1475,7 +1614,7 @@ type TokenRefreshResponse struct { func (x *TokenRefreshResponse) Reset() { *x = TokenRefreshResponse{} - mi := &file_api_sam_proto_msgTypes[21] + mi := &file_api_sam_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1487,7 +1626,7 @@ func (x *TokenRefreshResponse) String() string { func (*TokenRefreshResponse) ProtoMessage() {} func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[21] + mi := &file_api_sam_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1500,7 +1639,7 @@ func (x *TokenRefreshResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRefreshResponse.ProtoReflect.Descriptor instead. func (*TokenRefreshResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{21} + return file_api_sam_proto_rawDescGZIP(), []int{23} } func (x *TokenRefreshResponse) GetBiscuitToken() []byte { @@ -1533,7 +1672,7 @@ type TokenRevokeRequest struct { func (x *TokenRevokeRequest) Reset() { *x = TokenRevokeRequest{} - mi := &file_api_sam_proto_msgTypes[22] + mi := &file_api_sam_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1545,7 +1684,7 @@ func (x *TokenRevokeRequest) String() string { func (*TokenRevokeRequest) ProtoMessage() {} func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[22] + mi := &file_api_sam_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1558,7 +1697,7 @@ func (x *TokenRevokeRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeRequest.ProtoReflect.Descriptor instead. func (*TokenRevokeRequest) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{22} + return file_api_sam_proto_rawDescGZIP(), []int{24} } func (x *TokenRevokeRequest) GetPeerId() string { @@ -1578,7 +1717,7 @@ type TokenRevokeResponse struct { func (x *TokenRevokeResponse) Reset() { *x = TokenRevokeResponse{} - mi := &file_api_sam_proto_msgTypes[23] + mi := &file_api_sam_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1590,7 +1729,7 @@ func (x *TokenRevokeResponse) String() string { func (*TokenRevokeResponse) ProtoMessage() {} func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { - mi := &file_api_sam_proto_msgTypes[23] + mi := &file_api_sam_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1603,7 +1742,7 @@ func (x *TokenRevokeResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TokenRevokeResponse.ProtoReflect.Descriptor instead. func (*TokenRevokeResponse) Descriptor() ([]byte, []int) { - return file_api_sam_proto_rawDescGZIP(), []int{23} + return file_api_sam_proto_rawDescGZIP(), []int{25} } func (x *TokenRevokeResponse) GetSuccess() bool { @@ -1631,17 +1770,18 @@ const file_api_sam_proto_rawDesc = "" + "\fAuthResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12\x18\n" + - "\abiscuit\x18\x03 \x01(\fR\abiscuit\"\xd8\x01\n" + + "\abiscuit\x18\x03 \x01(\fR\abiscuit\"\xeb\x01\n" + "\tMeshEvent\x12*\n" + "\x04type\x18\x01 \x01(\x0e2\x16.sam.v1.MeshEvent.TypeR\x04type\x12\x17\n" + "\apeer_id\x18\x02 \x01(\tR\x06peerId\x12\x1c\n" + "\ttimestamp\x18\x03 \x01(\x03R\ttimestamp\x12$\n" + "\x0enew_public_key\x18\x04 \x01(\fR\fnewPublicKey\x12\x1c\n" + - "\tsignature\x18\x05 \x01(\fR\tsignature\"$\n" + + "\tsignature\x18\x05 \x01(\fR\tsignature\"7\n" + "\x04Type\x12\n" + "\n" + "\x06BANNED\x10\x00\x12\x10\n" + - "\fKEY_ROTATION\x10\x01\"\x80\x01\n" + + "\fKEY_ROTATION\x10\x01\x12\x11\n" + + "\rPOLICY_UPDATE\x10\x02\"\x80\x01\n" + "\rEnrollRequest\x12\x10\n" + "\x03jwt\x18\x01 \x01(\tR\x03jwt\x12\x17\n" + "\apeer_id\x18\x02 \x01(\tR\x06peerId\x12\x1d\n" + @@ -1711,12 +1851,23 @@ const file_api_sam_proto_rawDesc = "" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\x12\x1d\n" + "\n" + - "expires_at\x18\x03 \x01(\x03R\texpiresAt\"\x18\n" + - "\x16PolicyConfigGetRequest\"<\n" + - "\x17PolicyConfigGetResponse\x12!\n" + - "\fyaml_content\x18\x01 \x01(\tR\vyamlContent\">\n" + - "\x19PolicyConfigUpdateRequest\x12!\n" + - "\fyaml_content\x18\x01 \x01(\tR\vyamlContent\"L\n" + + "expires_at\x18\x03 \x01(\x03R\texpiresAt\"\x9b\x01\n" + + "\n" + + "PolicyRole\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12'\n" + + "\x0fallowed_targets\x18\x02 \x03(\tR\x0eallowedTargets\x12)\n" + + "\x10allowed_services\x18\x03 \x03(\tR\x0fallowedServices\x12%\n" + + "\x0ecustom_datalog\x18\x04 \x03(\tR\rcustomDatalog\"=\n" + + "\rPolicyBinding\x12\x12\n" + + "\x04role\x18\x01 \x01(\tR\x04role\x12\x18\n" + + "\amembers\x18\x02 \x03(\tR\amembers\"\x18\n" + + "\x16PolicyConfigGetRequest\"v\n" + + "\x17PolicyConfigGetResponse\x12(\n" + + "\x05roles\x18\x01 \x03(\v2\x12.sam.v1.PolicyRoleR\x05roles\x121\n" + + "\bbindings\x18\x02 \x03(\v2\x15.sam.v1.PolicyBindingR\bbindings\"x\n" + + "\x19PolicyConfigUpdateRequest\x12(\n" + + "\x05roles\x18\x01 \x03(\v2\x12.sam.v1.PolicyRoleR\x05roles\x121\n" + + "\bbindings\x18\x02 \x03(\v2\x15.sam.v1.PolicyBindingR\bbindings\"L\n" + "\x1aPolicyConfigUpdateResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x14\n" + "\x05error\x18\x02 \x01(\tR\x05error\"/\n" + @@ -1759,7 +1910,7 @@ func file_api_sam_proto_rawDescGZIP() []byte { } var file_api_sam_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_api_sam_proto_msgTypes = make([]protoimpl.MessageInfo, 27) var file_api_sam_proto_goTypes = []any{ (EnrollmentStatus)(0), // 0: sam.v1.EnrollmentStatus (ServiceType)(0), // 1: sam.v1.ServiceType @@ -1779,29 +1930,35 @@ var file_api_sam_proto_goTypes = []any{ (*HubInfoResponse)(nil), // 15: sam.v1.HubInfoResponse (*RouterLeaseRequest)(nil), // 16: sam.v1.RouterLeaseRequest (*RouterLeaseResponse)(nil), // 17: sam.v1.RouterLeaseResponse - (*PolicyConfigGetRequest)(nil), // 18: sam.v1.PolicyConfigGetRequest - (*PolicyConfigGetResponse)(nil), // 19: sam.v1.PolicyConfigGetResponse - (*PolicyConfigUpdateRequest)(nil), // 20: sam.v1.PolicyConfigUpdateRequest - (*PolicyConfigUpdateResponse)(nil), // 21: sam.v1.PolicyConfigUpdateResponse - (*KeysResponse)(nil), // 22: sam.v1.KeysResponse - (*TokenRefreshRequest)(nil), // 23: sam.v1.TokenRefreshRequest - (*TokenRefreshResponse)(nil), // 24: sam.v1.TokenRefreshResponse - (*TokenRevokeRequest)(nil), // 25: sam.v1.TokenRevokeRequest - (*TokenRevokeResponse)(nil), // 26: sam.v1.TokenRevokeResponse - nil, // 27: sam.v1.CommandBackend.EnvEntry + (*PolicyRole)(nil), // 18: sam.v1.PolicyRole + (*PolicyBinding)(nil), // 19: sam.v1.PolicyBinding + (*PolicyConfigGetRequest)(nil), // 20: sam.v1.PolicyConfigGetRequest + (*PolicyConfigGetResponse)(nil), // 21: sam.v1.PolicyConfigGetResponse + (*PolicyConfigUpdateRequest)(nil), // 22: sam.v1.PolicyConfigUpdateRequest + (*PolicyConfigUpdateResponse)(nil), // 23: sam.v1.PolicyConfigUpdateResponse + (*KeysResponse)(nil), // 24: sam.v1.KeysResponse + (*TokenRefreshRequest)(nil), // 25: sam.v1.TokenRefreshRequest + (*TokenRefreshResponse)(nil), // 26: sam.v1.TokenRefreshResponse + (*TokenRevokeRequest)(nil), // 27: sam.v1.TokenRevokeRequest + (*TokenRevokeResponse)(nil), // 28: sam.v1.TokenRevokeResponse + nil, // 29: sam.v1.CommandBackend.EnvEntry } var file_api_sam_proto_depIdxs = []int32{ 2, // 0: sam.v1.MeshEvent.type:type_name -> sam.v1.MeshEvent.Type 0, // 1: sam.v1.BootstrapEnrollResponse.status:type_name -> sam.v1.EnrollmentStatus 1, // 2: sam.v1.ServiceInfo.type:type_name -> sam.v1.ServiceType - 27, // 3: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry + 29, // 3: sam.v1.CommandBackend.env:type_name -> sam.v1.CommandBackend.EnvEntry 11, // 4: sam.v1.RegisterServiceRequest.service:type_name -> sam.v1.ServiceInfo 12, // 5: sam.v1.RegisterServiceRequest.command:type_name -> sam.v1.CommandBackend - 6, // [6:6] is the sub-list for method output_type - 6, // [6:6] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name + 18, // 6: sam.v1.PolicyConfigGetResponse.roles:type_name -> sam.v1.PolicyRole + 19, // 7: sam.v1.PolicyConfigGetResponse.bindings:type_name -> sam.v1.PolicyBinding + 18, // 8: sam.v1.PolicyConfigUpdateRequest.roles:type_name -> sam.v1.PolicyRole + 19, // 9: sam.v1.PolicyConfigUpdateRequest.bindings:type_name -> sam.v1.PolicyBinding + 10, // [10:10] is the sub-list for method output_type + 10, // [10:10] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_api_sam_proto_init() } @@ -1819,7 +1976,7 @@ func file_api_sam_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_sam_proto_rawDesc), len(file_api_sam_proto_rawDesc)), NumEnums: 3, - NumMessages: 25, + NumMessages: 27, NumExtensions: 0, NumServices: 0, }, diff --git a/api/sam.proto b/api/sam.proto index 9828c97f..1a73a1e4 100644 --- a/api/sam.proto +++ b/api/sam.proto @@ -35,6 +35,7 @@ message MeshEvent { enum Type { BANNED = 0; KEY_ROTATION = 1; + POLICY_UPDATE = 2; } Type type = 1; string peer_id = 2; @@ -141,14 +142,28 @@ message RouterLeaseResponse { int64 expires_at = 3; } +message PolicyRole { + string name = 1; + repeated string allowed_targets = 2; + repeated string allowed_services = 3; + repeated string custom_datalog = 4; +} + +message PolicyBinding { + string role = 1; + repeated string members = 2; +} + message PolicyConfigGetRequest {} message PolicyConfigGetResponse { - string yaml_content = 1; + repeated PolicyRole roles = 1; + repeated PolicyBinding bindings = 2; } message PolicyConfigUpdateRequest { - string yaml_content = 1; + repeated PolicyRole roles = 1; + repeated PolicyBinding bindings = 2; } message PolicyConfigUpdateResponse { diff --git a/charts/sam-mesh/Chart.yaml b/charts/sam-mesh/Chart.yaml new file mode 100644 index 00000000..e89a669e --- /dev/null +++ b/charts/sam-mesh/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: sam-mesh +description: A Helm chart for deploying the Sovereign Agent Mesh (SAM) Control Plane, Router, and Console +type: application +version: 0.1.0 +appVersion: "1.0.0" diff --git a/charts/sam-mesh/templates/_helpers.tpl b/charts/sam-mesh/templates/_helpers.tpl new file mode 100644 index 00000000..26c19ec2 --- /dev/null +++ b/charts/sam-mesh/templates/_helpers.tpl @@ -0,0 +1,51 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "sam-mesh.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "sam-mesh.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "sam-mesh.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "sam-mesh.labels" -}} +helm.sh/chart: {{ include "sam-mesh.chart" . }} +{{ include "sam-mesh.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "sam-mesh.selectorLabels" -}} +app.kubernetes.io/name: {{ include "sam-mesh.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} diff --git a/charts/sam-mesh/templates/bootstrap-job.yaml b/charts/sam-mesh/templates/bootstrap-job.yaml new file mode 100644 index 00000000..aac41560 --- /dev/null +++ b/charts/sam-mesh/templates/bootstrap-job.yaml @@ -0,0 +1,136 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "sam-mesh.fullname" . }}-bootstrap-sa + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "sam-mesh.fullname" . }}-bootstrap-role + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "create", "delete", "patch", "update"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "sam-mesh.fullname" . }}-bootstrap-rb + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +subjects: +- kind: ServiceAccount + name: {{ include "sam-mesh.fullname" . }}-bootstrap-sa + namespace: {{ .Release.Namespace }} +roleRef: + kind: Role + name: {{ include "sam-mesh.fullname" . }}-bootstrap-role + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "sam-mesh.fullname" . }}-bootstrap + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-delete-policy": before-hook-creation + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + activeDeadlineSeconds: 300 + template: + metadata: + labels: + app: {{ include "sam-mesh.fullname" . }}-bootstrap + spec: + serviceAccountName: {{ include "sam-mesh.fullname" . }}-bootstrap-sa + restartPolicy: OnFailure + containers: + - name: bootstrap + image: curlimages/curl:8.6.0 + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: NAMESPACE + value: {{ .Release.Namespace | quote }} + - name: SECRET_NAME + value: "{{ include "sam-mesh.fullname" . }}-router-token" + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-secrets + key: admin-token + command: ["/bin/sh", "-c"] + args: + - | + set -e + CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt + K8S_TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) + CP_URL="http://{{ include "sam-mesh.fullname" . }}-control-plane:8080" + + echo "Waiting for Control Plane to be ready..." + until curl -sf "${CP_URL}/info" > /dev/null; do + echo "Control plane not ready yet, sleeping..." + sleep 2 + done + + echo "Seeding default policies..." + {{- $bindings := .Values.bootstrap.bindings -}} + {{- if not $bindings -}} + {{- $defaultRouterSA := printf "user:system:serviceaccount:%s:%s-router-sa" .Release.Namespace (include "sam-mesh.fullname" .) -}} + {{- $bindings = list + (dict "role" "sam:role:router" "members" (list "group:routers" $defaultRouterSA)) + (dict "role" "sam-admin" "members" (list (printf "user:system:serviceaccount:%s:node-a-sa" .Release.Namespace) (printf "user:system:serviceaccount:%s:node-b-sa" .Release.Namespace) (printf "user:system:serviceaccount:%s:node-c-sa" .Release.Namespace) (printf "user:system:serviceaccount:%s:local-node-sa" .Release.Namespace))) + (dict "role" "sam:role:sambox" "members" (list (printf "user:system:serviceaccount:%s:sam-box-sa" .Release.Namespace))) + (dict "role" "sam:role:node" "members" (list "group:data-scientist" "group:users")) + -}} + {{- end }} + curl -sf -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -d '{ + "roles": [ + {"name": "sam-admin", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:sambox", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:node", "allowed_services": ["mcp://calculator", "mcp://db-agent", "mcp://http-tool", "mcp://stdio-tool", "system://sam.catalog"], "allowed_targets": ["*"]} + ], + "bindings": {{ toJson $bindings }} + }' \ + "${CP_URL}/policies" >/dev/null + + + echo "Generating bootstrap token for sam-router..." + TOKEN_RESP=$(curl -sf -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${ADMIN_TOKEN}" \ + -d '{"role": "sam:role:router", "max_usages": 999999}' \ + "${CP_URL}/admin/bootstrap-tokens") + + ROUTER_TOKEN=$(echo "${TOKEN_RESP}" | grep -o '"token":"[^"]*' | cut -d'"' -f4) + if [ -z "${ROUTER_TOKEN}" ]; then + echo "Failed to extract router token from response: ${TOKEN_RESP}" + exit 1 + fi + + echo "Writing bootstrap token to Kubernetes secret ${SECRET_NAME}..." + curl -sf --cacert $CACERT -X PATCH \ + -H "Authorization: Bearer $K8S_TOKEN" \ + -H "Content-Type: application/apply-patch+yaml" \ + -d '{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { + "name": "'"${SECRET_NAME}"'" + }, + "type": "Opaque", + "stringData": { + "token": "'"${ROUTER_TOKEN}"'" + } + }' \ + "https://kubernetes.default.svc/api/v1/namespaces/${NAMESPACE}/secrets/${SECRET_NAME}?fieldManager=sam-bootstrap&force=true" > /dev/null + + echo "Bootstrap completed successfully!" diff --git a/charts/sam-mesh/templates/console-deployment.yaml b/charts/sam-mesh/templates/console-deployment.yaml new file mode 100644 index 00000000..2887150b --- /dev/null +++ b/charts/sam-mesh/templates/console-deployment.yaml @@ -0,0 +1,37 @@ +{{- if .Values.console.enabled -}} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sam-mesh.fullname" . }}-console + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.console.replicaCount }} + selector: + matchLabels: + app: {{ include "sam-mesh.fullname" . }}-console + template: + metadata: + labels: + app: {{ include "sam-mesh.fullname" . }}-console + spec: + containers: + - name: sam-console + image: "{{ .Values.console.image.repository }}:{{ .Values.global.imageTag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-secrets + key: admin-token + ports: + - containerPort: 8081 + name: http + protocol: TCP + args: + - "--hub=http://{{ include "sam-mesh.fullname" . }}-control-plane:{{ .Values.controlPlane.service.port }}" + - "--bind-addr=:8081" + - "--static-dir=/app/public" + - "--admin-token=$(ADMIN_TOKEN)" +{{- end }} diff --git a/charts/sam-mesh/templates/console-service.yaml b/charts/sam-mesh/templates/console-service.yaml new file mode 100644 index 00000000..a2438151 --- /dev/null +++ b/charts/sam-mesh/templates/console-service.yaml @@ -0,0 +1,20 @@ +{{- if .Values.console.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sam-mesh.fullname" . }}-console + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + type: {{ .Values.console.service.type }} + ports: + - name: http + port: {{ .Values.console.service.port }} + targetPort: 8081 + {{- if and (eq .Values.console.service.type "NodePort") .Values.console.service.nodePort }} + nodePort: {{ .Values.console.service.nodePort }} + {{- end }} + protocol: TCP + selector: + app: {{ include "sam-mesh.fullname" . }}-console +{{- end }} diff --git a/charts/sam-mesh/templates/control-plane-deployment.yaml b/charts/sam-mesh/templates/control-plane-deployment.yaml new file mode 100644 index 00000000..a3d1e202 --- /dev/null +++ b/charts/sam-mesh/templates/control-plane-deployment.yaml @@ -0,0 +1,71 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "sam-mesh.fullname" . }}-control-plane + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.controlPlane.replicaCount }} + selector: + matchLabels: + app: {{ include "sam-mesh.fullname" . }}-control-plane + template: + metadata: + labels: + app: {{ include "sam-mesh.fullname" . }}-control-plane + spec: + containers: + - name: sam-control-plane + image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.global.imageTag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: DB_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-secrets + key: db-password + - name: ADMIN_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-secrets + key: admin-token + ports: + - containerPort: 8080 + name: http + {{- if .Values.controlPlane.hostPort }} + hostPort: {{ .Values.controlPlane.hostPort }} + {{- end }} + protocol: TCP + readinessProbe: + httpGet: + path: /info + port: http + periodSeconds: 5 + livenessProbe: + httpGet: + path: /info + port: http + periodSeconds: 15 + args: + - "--bind-address=0.0.0.0:8080" + {{- if eq .Values.database.type "postgres" }} + - "--db-driver=postgres" + {{- $dbHost := printf "%s-db" (include "sam-mesh.fullname" .) }} + {{- if not .Values.database.postgres.deployInternal }} + {{- $dbHost = .Values.database.postgres.host }} + {{- end }} + - "--db-dsn=postgres://{{ .Values.database.postgres.user }}:$(DB_PASSWORD)@{{ $dbHost }}:{{ .Values.database.postgres.port | default 5432 }}/{{ .Values.database.postgres.database }}?sslmode={{ .Values.database.postgres.sslmode | default "disable" }}" + {{- else }} + - "--db-driver=sqlite" + - "--db-dsn=/data/control-plane.db" + {{- end }} + - "--issuer={{ .Values.controlPlane.oidcIssuer }}" + - "--allowed-audiences={{ .Values.controlPlane.allowedAudiences }}" + - "--admin-token=$(ADMIN_TOKEN)" + {{- if .Values.controlPlane.autoApproveEnrollment }} + - "--auto-approve-enrollment" + {{- end }} + {{- if .Values.controlPlane.insecureSkipTlsVerify }} + - "--insecure-skip-tls-verify" + {{- end }} + - "--log-level={{ .Values.controlPlane.logLevel }}" diff --git a/charts/sam-mesh/templates/control-plane-service.yaml b/charts/sam-mesh/templates/control-plane-service.yaml new file mode 100644 index 00000000..183fb86f --- /dev/null +++ b/charts/sam-mesh/templates/control-plane-service.yaml @@ -0,0 +1,18 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sam-mesh.fullname" . }}-control-plane + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + type: {{ .Values.controlPlane.service.type }} + ports: + - name: http + port: {{ .Values.controlPlane.service.port }} + targetPort: 8080 + {{- if and (eq .Values.controlPlane.service.type "NodePort") .Values.controlPlane.service.nodePort }} + nodePort: {{ .Values.controlPlane.service.nodePort }} + {{- end }} + protocol: TCP + selector: + app: {{ include "sam-mesh.fullname" . }}-control-plane diff --git a/charts/sam-mesh/templates/db-service.yaml b/charts/sam-mesh/templates/db-service.yaml new file mode 100644 index 00000000..3bb4a09e --- /dev/null +++ b/charts/sam-mesh/templates/db-service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.database.postgres.deployInternal -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sam-mesh.fullname" . }}-db + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + type: ClusterIP + ports: + - name: postgres + port: 5432 + targetPort: 5432 + protocol: TCP + selector: + app: {{ include "sam-mesh.fullname" . }}-db +{{- end }} diff --git a/charts/sam-mesh/templates/db-statefulset.yaml b/charts/sam-mesh/templates/db-statefulset.yaml new file mode 100644 index 00000000..0705d9b7 --- /dev/null +++ b/charts/sam-mesh/templates/db-statefulset.yaml @@ -0,0 +1,48 @@ +{{- if .Values.database.postgres.deployInternal -}} +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "sam-mesh.fullname" . }}-db + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + serviceName: {{ include "sam-mesh.fullname" . }}-db + replicas: 1 + selector: + matchLabels: + app: {{ include "sam-mesh.fullname" . }}-db + template: + metadata: + labels: + app: {{ include "sam-mesh.fullname" . }}-db + spec: + containers: + - name: postgres + image: "{{ .Values.database.postgres.image.repository }}:{{ .Values.database.postgres.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + env: + - name: POSTGRES_DB + value: {{ .Values.database.postgres.database | quote }} + - name: POSTGRES_USER + value: {{ .Values.database.postgres.user | quote }} + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-secrets + key: db-password + ports: + - containerPort: 5432 + name: postgres + protocol: TCP + volumeMounts: + - name: db-data + mountPath: /var/lib/postgresql/data + volumeClaimTemplates: + - metadata: + name: db-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: {{ .Values.database.postgres.storageSize | quote }} +{{- end }} diff --git a/development/kind/13-dex.yaml b/charts/sam-mesh/templates/dex-deployment.yaml similarity index 58% rename from development/kind/13-dex.yaml rename to charts/sam-mesh/templates/dex-deployment.yaml index 64f95ac6..f4a0a614 100644 --- a/development/kind/13-dex.yaml +++ b/charts/sam-mesh/templates/dex-deployment.yaml @@ -1,11 +1,13 @@ +{{- if .Values.dex.enabled -}} apiVersion: v1 kind: ConfigMap metadata: - name: dex-config - namespace: ${NAMESPACE} + name: {{ include "sam-mesh.fullname" . }}-dex-config + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} data: config.yaml: | - issuer: http://dex:5556/dex + issuer: http://{{ include "sam-mesh.fullname" . }}-dex:5556/dex storage: type: memory web: @@ -30,23 +32,25 @@ data: apiVersion: apps/v1 kind: Deployment metadata: - name: dex - namespace: ${NAMESPACE} + name: {{ include "sam-mesh.fullname" . }}-dex + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} spec: replicas: 1 selector: matchLabels: - app: dex + app: {{ include "sam-mesh.fullname" . }}-dex template: metadata: labels: - app: dex + app: {{ include "sam-mesh.fullname" . }}-dex spec: nodeSelector: sam-role: hub containers: - name: dex - image: ghcr.io/dexidp/dex:v2.41.1 + image: "{{ .Values.dex.image.repository }}:{{ .Values.dex.image.tag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} args: - dex - serve @@ -60,19 +64,23 @@ spec: volumes: - name: config configMap: - name: dex-config + name: {{ include "sam-mesh.fullname" . }}-dex-config --- apiVersion: v1 kind: Service metadata: - name: dex - namespace: ${NAMESPACE} + name: {{ include "sam-mesh.fullname" . }}-dex + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} spec: type: NodePort selector: - app: dex + app: {{ include "sam-mesh.fullname" . }}-dex ports: - name: http port: 5556 targetPort: 5556 - nodePort: 30556 + {{- if .Values.dex.nodePort }} + nodePort: {{ .Values.dex.nodePort }} + {{- end }} +{{- end }} diff --git a/charts/sam-mesh/templates/router-service.yaml b/charts/sam-mesh/templates/router-service.yaml new file mode 100644 index 00000000..0735681b --- /dev/null +++ b/charts/sam-mesh/templates/router-service.yaml @@ -0,0 +1,24 @@ +{{- if .Values.router.enabled -}} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "sam-mesh.fullname" . }}-router + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + type: {{ .Values.router.service.type }} + ports: + - name: p2p-tcp + port: {{ .Values.router.service.port }} + targetPort: 4501 + {{- if and (eq .Values.router.service.type "NodePort") .Values.router.service.nodePort }} + nodePort: {{ .Values.router.service.nodePort }} + {{- end }} + protocol: TCP + - name: p2p-udp + port: {{ .Values.router.service.port }} + targetPort: 4501 + protocol: UDP + selector: + app: {{ include "sam-mesh.fullname" . }}-router +{{- end }} diff --git a/charts/sam-mesh/templates/router-statefulset.yaml b/charts/sam-mesh/templates/router-statefulset.yaml new file mode 100644 index 00000000..94c675c8 --- /dev/null +++ b/charts/sam-mesh/templates/router-statefulset.yaml @@ -0,0 +1,91 @@ +{{- if .Values.router.enabled -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "sam-mesh.fullname" . }}-router-sa + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ include "sam-mesh.fullname" . }}-router + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +spec: + serviceName: {{ include "sam-mesh.fullname" . }}-router + replicas: {{ .Values.router.replicaCount }} + selector: + matchLabels: + app: {{ include "sam-mesh.fullname" . }}-router + template: + metadata: + labels: + app: {{ include "sam-mesh.fullname" . }}-router + spec: + serviceAccountName: {{ include "sam-mesh.fullname" . }}-router-sa + containers: + - name: sam-router + image: "{{ .Values.router.image.repository }}:{{ .Values.global.imageTag }}" + imagePullPolicy: {{ .Values.global.imagePullPolicy }} + ports: + - containerPort: 4501 + name: p2p-tcp + {{- if .Values.router.hostPort }} + hostPort: {{ .Values.router.hostPort }} + {{- end }} + protocol: TCP + - containerPort: 4501 + name: p2p-udp + {{- if .Values.router.hostPort }} + hostPort: {{ .Values.router.hostPort }} + {{- end }} + protocol: UDP + env: + {{- if not .Values.router.useOidcToken }} + - name: BOOTSTRAP_TOKEN + valueFrom: + secretKeyRef: + name: {{ include "sam-mesh.fullname" . }}-router-token + key: token + {{- end }} + args: + - "--control-plane=http://{{ include "sam-mesh.fullname" . }}-control-plane:8080" + - "--listen=/ip4/0.0.0.0/tcp/4501" + - "--listen=/ip4/0.0.0.0/udp/4501/quic-v1" + {{- if .Values.router.externalAddrs }} + {{- range .Values.router.externalAddrs }} + - "--external-addr={{ . }}" + {{- end }} + {{- else }} + - "--external-addr=/dns4/{{ include "sam-mesh.fullname" . }}-router/tcp/4501" + {{- end }} + - "--keys-path=/data/router.key" + - "--allow-loopback" + - "--log-level={{ .Values.router.logLevel }}" + {{- if .Values.router.useOidcToken }} + - "--jwt-path=/var/run/secrets/tokens/sam-token" + {{- else }} + - "--bootstrap-token=$(BOOTSTRAP_TOKEN)" + {{- end }} + volumeMounts: + - name: router-data + mountPath: /data + {{- if .Values.router.useOidcToken }} + - name: sam-token + mountPath: /var/run/secrets/tokens + readOnly: true + {{- end }} + volumes: + - name: router-data + emptyDir: {} + {{- if .Values.router.useOidcToken }} + - name: sam-token + projected: + sources: + - serviceAccountToken: + path: sam-token + expirationSeconds: 3600 + audience: "sam-hub-audience" + {{- end }} +{{- end }} diff --git a/charts/sam-mesh/templates/secrets.yaml b/charts/sam-mesh/templates/secrets.yaml new file mode 100644 index 00000000..c7b6e577 --- /dev/null +++ b/charts/sam-mesh/templates/secrets.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "sam-mesh.fullname" . }}-secrets + labels: + {{- include "sam-mesh.labels" . | nindent 4 }} +type: Opaque +data: + admin-token: {{ .Values.controlPlane.adminToken | b64enc | quote }} + db-password: {{ .Values.database.postgres.password | b64enc | quote }} diff --git a/charts/sam-mesh/values.yaml b/charts/sam-mesh/values.yaml new file mode 100644 index 00000000..2a2baead --- /dev/null +++ b/charts/sam-mesh/values.yaml @@ -0,0 +1,72 @@ +global: + imageTag: local + imagePullPolicy: IfNotPresent + +controlPlane: + replicaCount: 2 + image: + repository: sam-control-plane + logLevel: info + adminToken: super-secret-admin-token + autoApproveEnrollment: true + insecureSkipTlsVerify: true + oidcIssuer: "http://sam-mesh-dex:5556/dex" + allowedAudiences: "sam-mesh-audience,sam-hub-audience" + service: + type: ClusterIP + port: 8080 + nodePort: null + +database: + type: postgres + postgres: + deployInternal: true + image: + repository: postgres + tag: 16-alpine + user: sam + password: sam-secret-password + database: sam_mesh + port: 5432 + sslmode: disable + storageSize: 1Gi + +router: + enabled: true + replicaCount: 1 + image: + repository: sam-router + useOidcToken: false + logLevel: info + service: + type: ClusterIP + port: 4501 + nodePort: null + +console: + enabled: true + replicaCount: 1 + image: + repository: sam-console + service: + type: ClusterIP + port: 8081 + nodePort: null + +dex: + enabled: false + nodePort: 30556 + image: + repository: dexidp/dex + tag: v2.39.0 + google: + clientId: "" + clientSecret: "" + github: + clientId: "" + clientSecret: "" + cliOAuthSecret: "" + +bootstrap: + bindings: [] + diff --git a/cmd/sam-control-plane/main.go b/cmd/sam-control-plane/main.go index acf46d06..d731b6b4 100644 --- a/cmd/sam-control-plane/main.go +++ b/cmd/sam-control-plane/main.go @@ -33,7 +33,6 @@ var ( dbDSN string oidcIssuer string allowedAudiencesFlag string - policyFile string keyRotationInterval time.Duration keyGracePeriod time.Duration leaseDuration time.Duration @@ -98,7 +97,6 @@ func main() { KeyRotationInterval: keyRotationInterval, KeyGracePeriod: keyGracePeriod, InsecureSkipTLSVerify: insecureSkipTLSVerify, - PolicyPath: policyFile, BiscuitTimeout: 10 * time.Second, AdminToken: adminToken, AutoApproveEnrollment: autoApproveEnrollment, @@ -128,7 +126,6 @@ func main() { rootCmd.PersistentFlags().StringVar(&dbDSN, "db-dsn", "control-plane.db", "Database DSN/Connection URL") rootCmd.Flags().StringVar(&oidcIssuer, "issuer", "", "OIDC Issuer URL (comma-separated)") rootCmd.Flags().StringVar(&allowedAudiencesFlag, "allowed-audiences", api.DefaultAudience, "Comma-separated list of allowed OIDC audiences") - rootCmd.Flags().StringVar(&policyFile, "policy-file", "policies.yaml", "Path to policies.yaml (bootstrapping only)") rootCmd.Flags().DurationVar(&keyRotationInterval, "key-rotation-interval", 24*time.Hour, "Key rotation interval (e.g. 24h). 0 disables rotation.") rootCmd.Flags().DurationVar(&keyGracePeriod, "key-grace-period", 1*time.Hour, "Key grace period for rotated keys.") rootCmd.Flags().DurationVar(&leaseDuration, "lease-duration", 15*time.Minute, "Router lease registration TTL.") diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index 7bb58369..d016d7f8 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -86,6 +86,7 @@ var ( dhtMaxRecordAgeFlag time.Duration dhtLookupLimitFlag int discoveryConcurrencyFlag int + policySyncIntervalFlag time.Duration ) var logger = golog.Logger("sam-node-cli") @@ -228,6 +229,7 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, HubConnectTimeout: hubConnectTimeoutFlag, RequiredRole: api.RoleNode, + PolicySyncInterval: policySyncIntervalFlag, DHTProviderAddrTTL: dhtProviderAddrTTLFlag, DHTMaxRecordAge: dhtMaxRecordAgeFlag, DHTLookupLimit: dhtLookupLimitFlag, @@ -292,6 +294,7 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, HubConnectTimeout: hubConnectTimeoutFlag, RequiredRole: api.RoleNode, + PolicySyncInterval: policySyncIntervalFlag, DHTProviderAddrTTL: dhtProviderAddrTTLFlag, DHTMaxRecordAge: dhtMaxRecordAgeFlag, DHTLookupLimit: dhtLookupLimitFlag, @@ -353,6 +356,7 @@ func main() { AutoRelayBackoff: autoRelayBackoffFlag, HubConnectTimeout: hubConnectTimeoutFlag, RequiredRole: api.RoleNode, + PolicySyncInterval: policySyncIntervalFlag, }) if err != nil { logger.Fatalf("Failed to initialize node after enrollment: %v", err) @@ -512,6 +516,7 @@ func main() { AutoRelayBackoff: 3 * time.Second, HubConnectTimeout: hubConnectTimeoutFlag, RequiredRole: api.RoleNode, + PolicySyncInterval: policySyncIntervalFlag, }) if err != nil { logger.Fatalf("Failed to initialize node for enrollment: %v", err) @@ -574,6 +579,7 @@ func main() { runCmd.Flags().DurationVar(&dhtMaxRecordAgeFlag, "dht-max-record-age", 0, "Maximum age for DHT records (0s uses library default)") runCmd.Flags().IntVar(&dhtLookupLimitFlag, "dht-lookup-limit", 0, "Maximum number of providers to query from the DHT (0 uses default 20)") runCmd.Flags().IntVar(&discoveryConcurrencyFlag, "discovery-concurrency", 0, "Max concurrent catalog fetches during discovery (0 uses default 10)") + runCmd.Flags().DurationVar(&policySyncIntervalFlag, "policy-sync-interval", 1*time.Hour, "Interval for syncing mesh policy from the Hub") rootCmd.PersistentFlags().StringVar(&hubAddr, "hub", "", "Hub URL") rootCmd.PersistentFlags().StringVar(&configFile, "config", node.DefaultConfigFile, "Path to sam-node.yaml configuration file") rootCmd.PersistentFlags().StringVar(&oidcIssuerFlag, "oidc-issuer", "", "OIDC Issuer URL") diff --git a/development/examples/calc-mcp/requirements.txt b/development/examples/calc-mcp/requirements.txt index c413b608..7ba9cdc1 100644 --- a/development/examples/calc-mcp/requirements.txt +++ b/development/examples/calc-mcp/requirements.txt @@ -1 +1 @@ -mcp>=1.0.0 +mcp[cli]>=1.0.0,<2.0.0 diff --git a/development/kind/10-control-plane.yaml b/development/kind/10-control-plane.yaml deleted file mode 100644 index ed98bfa4..00000000 --- a/development/kind/10-control-plane.yaml +++ /dev/null @@ -1,166 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: sam-control-plane - namespace: ${NAMESPACE} -spec: - type: ClusterIP - selector: - app: sam-control-plane - ports: - - { name: http, port: 8080, targetPort: 8080, protocol: TCP } ---- -apiVersion: v1 -kind: Service -metadata: - name: sam-db - namespace: ${NAMESPACE} -spec: - type: ClusterIP - selector: - app: sam-db - ports: - - { name: postgres, port: 5432, targetPort: 5432, protocol: TCP } ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: sam-db - namespace: ${NAMESPACE} -spec: - serviceName: sam-db - replicas: 1 - selector: - matchLabels: - app: sam-db - template: - metadata: - labels: - app: sam-db - spec: - containers: - - name: postgres - image: postgres:16-alpine - env: - - name: POSTGRES_DB - value: sam_mesh - - name: POSTGRES_USER - value: sam - - name: POSTGRES_PASSWORD - value: sam-secret-password - ports: - - containerPort: 5432 - protocol: TCP - volumeMounts: - - name: db-data - mountPath: /var/lib/postgresql/data - volumeClaimTemplates: - - metadata: - name: db-data - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 1Gi ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sam-control-plane - namespace: ${NAMESPACE} -spec: - replicas: 2 - selector: - matchLabels: - app: sam-control-plane - template: - metadata: - labels: - app: sam-control-plane - spec: - nodeSelector: - sam-role: hub - containers: - - name: sam-control-plane - image: sam-control-plane:${IMAGE_TAG} - imagePullPolicy: IfNotPresent - args: - - "--bind-address=0.0.0.0:8080" - - "--db-driver=postgres" - - "--db-dsn=postgres://sam:sam-secret-password@sam-db:5432/sam_mesh?sslmode=disable" - - "--issuer=${CONTROL_PLANE_ISSUERS}" - - "--allowed-audiences=${ALLOWED_AUDIENCES}" - - "--policy-file=/etc/sam/policies/policies.yaml" - - "--insecure-skip-tls-verify" - - "--admin-token=super-secret-admin-token" - ports: - - { containerPort: 8080, name: http, protocol: TCP } - readinessProbe: - httpGet: - path: /info - port: http - periodSeconds: 2 - volumeMounts: - - name: policies-volume - mountPath: /etc/sam/policies - readOnly: true - volumes: - - name: policies-volume - configMap: - name: sam-control-plane-policies ---- -# Host-facing access for a locally-built sam-node. Reachable on the host at -# 127.0.0.1:9090 via the control-plane extraPortMappings. -apiVersion: v1 -kind: Service -metadata: - name: sam-control-plane-nodeport - namespace: ${NAMESPACE} -spec: - type: NodePort - selector: - app: sam-control-plane - ports: - - { name: http, port: 8080, targetPort: 8080, nodePort: 30090, protocol: TCP } ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: sam-control-plane-policies - namespace: ${NAMESPACE} -data: - policies.yaml: | - version: "v1alpha1" - bindings: - - members: ["user:system:serviceaccount:${NAMESPACE}:node-a-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:node-b-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:node-c-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:node-d-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:node-e-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:local-node-sa"] - role: "sam-admin" - - members: ["user:system:serviceaccount:${NAMESPACE}:sam-box-sa"] - role: "sam:role:sambox" - - members: ["group:routers", "user:system:serviceaccount:${NAMESPACE}:sam-router-sa"] - role: "sam:role:router" - roles: - sam-admin: - allowed_services: - - "*" - allowed_targets: - - "*" - "sam:role:sambox": - allowed_services: - - "*" - allowed_targets: - - "*" - "sam:role:router": - allowed_services: - - "*" - allowed_targets: - - "*" diff --git a/development/kind/11-router.yaml b/development/kind/11-router.yaml deleted file mode 100644 index fe54f7fb..00000000 --- a/development/kind/11-router.yaml +++ /dev/null @@ -1,84 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: sam-router-sa - namespace: ${NAMESPACE} ---- -apiVersion: v1 -kind: Service -metadata: - name: sam-router - namespace: ${NAMESPACE} -spec: - type: ClusterIP - selector: - app: sam-router - ports: - - { name: p2p-tcp, port: 4501, targetPort: 4501, protocol: TCP } - - { name: p2p-udp, port: 4501, targetPort: 4501, protocol: UDP } ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: sam-router - namespace: ${NAMESPACE} -spec: - serviceName: "sam-router" - replicas: 1 - selector: - matchLabels: - app: sam-router - template: - metadata: - labels: - app: sam-router - spec: - serviceAccountName: sam-router-sa - nodeSelector: - sam-role: hub - containers: - - name: sam-router - image: sam-router:${IMAGE_TAG} - imagePullPolicy: IfNotPresent - ports: - - { containerPort: 4501, name: p2p-tcp, protocol: TCP } - - { containerPort: 4501, name: p2p-udp, protocol: UDP } - args: - - "--control-plane=http://sam-control-plane.${NAMESPACE}.svc.cluster.local:8080" - - "--listen=/ip4/0.0.0.0/tcp/4501" - - "--listen=/ip4/0.0.0.0/udp/4501/quic-v1" - - "--external-addr=/dns4/sam-router.${NAMESPACE}.svc.cluster.local/tcp/4501" - - "--external-addr=/ip4/127.0.0.1/tcp/4001" - - "--jwt-path=/var/run/secrets/tokens/sam-token" - - "--keys-path=/data/router.key" - - "--allow-loopback" - volumeMounts: - - name: sam-token - mountPath: /var/run/secrets/tokens - readOnly: true - - name: router-data - mountPath: /data - volumes: - - name: sam-token - projected: - sources: - - serviceAccountToken: - path: sam-token - expirationSeconds: 3600 - audience: "sam-hub-audience" - - name: router-data - emptyDir: {} ---- -# Host-facing access for a locally-built sam-node. Reachable on the host at -# 127.0.0.1:4001 via the control-plane extraPortMappings. -apiVersion: v1 -kind: Service -metadata: - name: sam-router-nodeport - namespace: ${NAMESPACE} -spec: - type: NodePort - selector: - app: sam-router - ports: - - { name: p2p-tcp, port: 4501, targetPort: 4501, nodePort: 30401, protocol: TCP } diff --git a/development/kind/12-console.yaml b/development/kind/12-console.yaml deleted file mode 100644 index 5329710c..00000000 --- a/development/kind/12-console.yaml +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: sam-console - namespace: ${NAMESPACE} -spec: - type: ClusterIP - selector: - app: sam-console - ports: - - { name: http, port: 80, targetPort: 8081, protocol: TCP } ---- -apiVersion: v1 -kind: Service -metadata: - name: sam-console-nodeport - namespace: ${NAMESPACE} -spec: - type: NodePort - selector: - app: sam-console - ports: - - { name: http, port: 8081, targetPort: 8081, nodePort: 30081, protocol: TCP } ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sam-console - namespace: ${NAMESPACE} -spec: - replicas: 1 - selector: - matchLabels: - app: sam-console - template: - metadata: - labels: - app: sam-console - spec: - nodeSelector: - sam-role: hub - containers: - - name: sam-console - image: sam-console:${IMAGE_TAG} - imagePullPolicy: IfNotPresent - args: - - "--hub=http://sam-control-plane:8080" - - "--bind-addr=:8081" - - "--static-dir=/app/public" - - "--admin-token=super-secret-admin-token" - ports: - - { containerPort: 8081, name: http, protocol: TCP } diff --git a/development/kind/node.template.yaml b/development/kind/node.template.yaml index 745b3660..cdfe477f 100644 --- a/development/kind/node.template.yaml +++ b/development/kind/node.template.yaml @@ -20,7 +20,7 @@ spec: app: ${NODE} spec: nodeSelector: - sam-role: ${NODE} + sam-role-${NODE}: "true" serviceAccountName: ${NODE}-sa containers: - name: sam-node @@ -28,7 +28,7 @@ spec: imagePullPolicy: IfNotPresent args: - "run" - - "--hub=http://sam-control-plane:8080" + - "--hub=${HUB_URL}" - "--jwt-path=/var/run/secrets/tokens/sam-token" - "--api-token=devtoken" - "--bind-addr=0.0.0.0:8080" diff --git a/development/kind/run.sh b/development/kind/run.sh index 07dbb725..d2b1243c 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -8,6 +8,10 @@ NAMESPACE="sam-kind" SESSION="sam-kind" KCTX="kind-${CLUSTER}" IMAGE_TAG="local" +HUB_URL="http://sam-mesh-control-plane:8080" +HELM="helm" + + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" @@ -19,6 +23,16 @@ check_prereqs() { for bin in "${bins[@]}"; do command -v "$bin" >/dev/null 2>&1 || { echo "missing prerequisite: $bin" >&2; exit 1; } done + + if ! command -v helm >/dev/null 2>&1; then + if [[ -x "${PROJECT_ROOT}/bin/helm" ]]; then + HELM="${PROJECT_ROOT}/bin/helm" + else + echo "missing prerequisite: helm (install helm or run: curl -fsSL https://get.helm.sh/helm-v3.12.0-linux-amd64.tar.gz | tar -xz -C bin/ --strip-components=1 linux-amd64/helm)" >&2 + exit 1 + fi + fi + if kind get clusters 2>/dev/null | grep -qx "${CLUSTER}"; then echo "kind cluster '${CLUSTER}' already exists; delete it first: make kind-down" >&2 exit 1 @@ -44,9 +58,10 @@ render_and_apply() { SIDECAR=$' - name: '"${name}"$'\n image: '"${name}:${IMAGE_TAG}"$'\n imagePullPolicy: IfNotPresent' CONFIG_VOLUME=$' - name: config\n configMap:\n name: '"${node}-config" fi - NODE="$node" CONFIG_ARG="$CONFIG_ARG" CONFIG_MOUNT="$CONFIG_MOUNT" SIDECAR="$SIDECAR" CONFIG_VOLUME="$CONFIG_VOLUME" \ - envsubst '${NODE} ${NAMESPACE} ${IMAGE_TAG} ${CONFIG_ARG} ${CONFIG_MOUNT} ${SIDECAR} ${CONFIG_VOLUME}' \ + NODE="$node" HUB_URL="$HUB_URL" CONFIG_ARG="$CONFIG_ARG" CONFIG_MOUNT="$CONFIG_MOUNT" SIDECAR="$SIDECAR" CONFIG_VOLUME="$CONFIG_VOLUME" \ + envsubst '${NODE} ${NAMESPACE} ${HUB_URL} ${IMAGE_TAG} ${CONFIG_ARG} ${CONFIG_MOUNT} ${SIDECAR} ${CONFIG_VOLUME}' \ < "${SCRIPT_DIR}/node.template.yaml" | kubectl --context "${KCTX}" apply -f - + } # logs: $1 = pane name (printed in-pane and set as the pane title); $2 = logs target. @@ -58,8 +73,8 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; } show_cluster_logs() { tmuxs kill-session -t "${SESSION}" 2>/dev/null || true - tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-control-plane')" \; set -t "${SESSION}" destroy-unattached off - tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-router')" + tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-mesh-control-plane')" \; set -t "${SESSION}" destroy-unattached off + tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-mesh-router')" for node in "${NODES[@]}"; do tmuxs split-window -t "${SESSION}:0" "$(logs "$node" "deploy/${node} -c sam-node")" tmuxs select-layout -t "${SESSION}:0" tiled @@ -119,7 +134,7 @@ read_mesh_nodes ISSUER="$(kubectl --context "${KCTX}" get --raw /.well-known/openid-configuration | jq -r .issuer)" [[ -n "$ISSUER" ]] || { echo "could not determine cluster OIDC issuer" >&2; exit 1; } -OIDC_ISSUER="${OIDC_ISSUER:-http://dex:5556/dex}" +OIDC_ISSUER="${OIDC_ISSUER:-http://sam-mesh-dex:5556/dex}" OIDC_CLIENT_ID="${OIDC_CLIENT_ID:-sam-console}" OIDC_CLIENT_SECRET="${OIDC_CLIENT_SECRET:-}" OIDC_REDIRECT_URL="${OIDC_REDIRECT_URL:-}" @@ -136,21 +151,42 @@ fi export NAMESPACE ISSUER IMAGE_TAG OIDC_ISSUER OIDC_CLIENT_ID OIDC_CLIENT_SECRET OIDC_REDIRECT_URL CONTROL_PLANE_ISSUERS ALLOWED_AUDIENCES -echo "== Applying control plane and router (issuer: ${CONTROL_PLANE_ISSUERS}) ==" -for f in "${SCRIPT_DIR}"/00-*.yaml "${SCRIPT_DIR}"/10-*.yaml "${SCRIPT_DIR}"/11-*.yaml "${SCRIPT_DIR}"/12-*.yaml "${SCRIPT_DIR}"/13-*.yaml; do - envsubst '${NAMESPACE} ${ISSUER} ${IMAGE_TAG} ${OIDC_ISSUER} ${OIDC_CLIENT_ID} ${OIDC_CLIENT_SECRET} ${OIDC_REDIRECT_URL} ${CONTROL_PLANE_ISSUERS} ${ALLOWED_AUDIENCES}' < "$f" | kubectl --context "${KCTX}" apply -f - -done +echo "== Applying namespace and RBAC cluster rules ==" +envsubst '${NAMESPACE}' < "${SCRIPT_DIR}/00-namespace-rbac.yaml" | kubectl --context "${KCTX}" apply -f - + +echo "== Deploying SAM Mesh via Helm ==" +"${HELM}" --kube-context "${KCTX}" upgrade --install sam-mesh "${PROJECT_ROOT}/charts/sam-mesh" --timeout 10m \ + --namespace "${NAMESPACE}" \ + --set global.imageTag="${IMAGE_TAG}" \ + --set controlPlane.oidcIssuer="${CONTROL_PLANE_ISSUERS//,/\\,}" \ + --set controlPlane.allowedAudiences="${ALLOWED_AUDIENCES//,/\\,}" \ + --set controlPlane.service.type=NodePort \ + --set controlPlane.service.nodePort=30090 \ + --set router.service.type=NodePort \ + --set router.service.nodePort=30401 \ + --set router.useOidcToken=true \ + --set router.externalAddrs[0]="/dns4/sam-mesh-router/tcp/4501" \ + --set router.externalAddrs[1]="/ip4/127.0.0.1/tcp/4001" \ + --set console.service.type=NodePort \ + --set console.service.nodePort=30081 \ + --set dex.enabled=true echo "== Waiting for database to be ready ==" -kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=ready --timeout=180s pod -l app=sam-db +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=ready --timeout=180s pod -l app=sam-mesh-db echo "== Waiting for control plane to be ready ==" -kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/sam-control-plane +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/sam-mesh-control-plane + +# Policy seeding is handled automatically by the Helm chart bootstrap job, we just wait for it to complete. +echo "== Waiting for bootstrap job to complete ==" +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=complete --timeout=120s job/sam-mesh-bootstrap + echo "== Waiting for router to be ready ==" -kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=ready --timeout=180s pod -l app=sam-router +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=ready --timeout=180s pod -l app=sam-mesh-router echo "== Waiting for console to be ready ==" -kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/sam-console +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/sam-mesh-console echo "== Waiting for Dex to be ready ==" -kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/dex +kubectl --context "${KCTX}" -n "${NAMESPACE}" wait --for=condition=available --timeout=180s deployment/sam-mesh-dex + echo "== Applying sam-nodes ==" for line in "${NODE_LINES[@]}"; do diff --git a/internal/controlplane/config.go b/internal/controlplane/config.go index 1a5bd4de..f7c8f9e8 100644 --- a/internal/controlplane/config.go +++ b/internal/controlplane/config.go @@ -31,7 +31,6 @@ type Options struct { KeyGracePeriod time.Duration InsecureSkipTLSVerify bool BiscuitTimeout time.Duration - PolicyPath string // Optional: path to bootstrap policy configuration AdminToken string // Optional: administrative bearer token for protecting policy and enrollment queue REST APIs AutoApproveEnrollment bool // If true, valid bootstrap token enrollment requests are immediately approved without administrative manual gate } diff --git a/internal/controlplane/mesh.go b/internal/controlplane/mesh.go new file mode 100644 index 00000000..0f708bde --- /dev/null +++ b/internal/controlplane/mesh.go @@ -0,0 +1,186 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "context" + "crypto/ed25519" + "fmt" + "sync" + "time" + + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/host" + "github.com/libp2p/go-libp2p/core/peer" + "github.com/libp2p/go-libp2p/core/peerstore" + "google.golang.org/protobuf/proto" + + "github.com/google/sam/api" + "github.com/google/sam/internal/storage" +) + +// ServiceAnnouncement represents service discovery details retrieved from the mesh. +type ServiceAnnouncement struct { + ServiceName string + ServiceType string + PeerID string + Addresses []string +} + +// NodeStatus represents mesh status information for a peer. +type NodeStatus struct { + PeerID string + IsReachable bool + Addresses []string + LastSeen time.Time +} + +// MeshAdapter defines the generic interface for Control Plane operations interacting with the Sovereign Agent Mesh. +type MeshAdapter interface { + // PublishEvent constructs, signs, and broadcasts a Control Plane MeshEvent (POLICY_UPDATE, BANNED, KEY_ROTATION). + PublishEvent(ctx context.Context, eventType api.MeshEvent_Type, peerID string, payload []byte) error + + // DiscoverServices queries active mesh nodes/DHT for services matching a type or pattern. + DiscoverServices(ctx context.Context, serviceType string) ([]*ServiceAnnouncement, error) + + // GetNodeStatus retrieves node reachability and status from the mesh. + GetNodeStatus(ctx context.Context, peerID string) (*NodeStatus, error) + + // Close gracefully releases any P2P host resources, streams, and PubSub topics. + Close() error +} + +// NopMeshAdapter provides a no-op implementation used when P2P mesh integration is disabled or in unit tests. +type NopMeshAdapter struct{} + +func NewNopMeshAdapter() *NopMeshAdapter { + return &NopMeshAdapter{} +} + +func (n *NopMeshAdapter) PublishEvent(ctx context.Context, eventType api.MeshEvent_Type, peerID string, payload []byte) error { + logger.Debugf("[NopMeshAdapter] PublishEvent skipped (P2P disabled): type=%v, peerID=%s", eventType, peerID) + return nil +} + +func (n *NopMeshAdapter) DiscoverServices(ctx context.Context, serviceType string) ([]*ServiceAnnouncement, error) { + return nil, nil +} + +func (n *NopMeshAdapter) GetNodeStatus(ctx context.Context, peerID string) (*NodeStatus, error) { + return nil, nil +} + +func (n *NopMeshAdapter) Close() error { + return nil +} + +// P2PMeshAdapter implements MeshAdapter using a libp2p Host and GossipSub subscriber/publisher. +type P2PMeshAdapter struct { + host host.Host + ps *pubsub.PubSub + topic *pubsub.Topic + store storage.Store + mu sync.Mutex +} + +func NewP2PMeshAdapter(h host.Host, ps *pubsub.PubSub, store storage.Store) (*P2PMeshAdapter, error) { + if h == nil || ps == nil || store == nil { + return nil, fmt.Errorf("host, pubsub, and store cannot be nil") + } + topic, err := ps.Join(api.GossipEvents) + if err != nil { + return nil, fmt.Errorf("failed to join gossip events topic %s: %w", api.GossipEvents, err) + } + + return &P2PMeshAdapter{ + host: h, + ps: ps, + topic: topic, + store: store, + }, nil +} + +func (p *P2PMeshAdapter) ConnectPeer(ctx context.Context, targetAddr string) error { + info, err := peer.AddrInfoFromString(targetAddr) + if err != nil { + return fmt.Errorf("invalid peer address string %q: %w", targetAddr, err) + } + p.host.Peerstore().AddAddrs(info.ID, info.Addrs, peerstore.PermanentAddrTTL) + if err := p.host.Connect(ctx, *info); err != nil { + return fmt.Errorf("failed to connect to peer %s: %w", info.ID, err) + } + return nil +} + +func (p *P2PMeshAdapter) PublishEvent(ctx context.Context, eventType api.MeshEvent_Type, peerID string, payload []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + + var privKey ed25519.PrivateKey + if p.store != nil { + key, _, err := p.store.GetCurrentKey(ctx) + if err != nil { + return fmt.Errorf("failed to retrieve signing key for event publishing: %w", err) + } + privKey = key + } + + event := &api.MeshEvent{ + Type: eventType, + PeerId: peerID, + Timestamp: time.Now().UnixMilli(), + NewPublicKey: payload, + } + + if privKey != nil { + eventData, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal event for signing: %w", err) + } + event.Signature = ed25519.Sign(privKey, eventData) + } + + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if err != nil { + return fmt.Errorf("failed to marshal signed mesh event: %w", err) + } + + if err := p.topic.Publish(ctx, data); err != nil { + return fmt.Errorf("failed to publish mesh event to topic %s: %w", api.GossipEvents, err) + } + + logger.Infof("[P2PMeshAdapter] Published MeshEvent %v to topic %s (peerID: %s)", eventType, api.GossipEvents, peerID) + return nil +} + +func (p *P2PMeshAdapter) DiscoverServices(ctx context.Context, serviceType string) ([]*ServiceAnnouncement, error) { + return nil, nil +} + +func (p *P2PMeshAdapter) GetNodeStatus(ctx context.Context, peerID string) (*NodeStatus, error) { + return nil, nil +} + +func (p *P2PMeshAdapter) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.topic != nil { + _ = p.topic.Close() + } + if p.host != nil { + return p.host.Close() + } + return nil +} diff --git a/internal/controlplane/mesh_test.go b/internal/controlplane/mesh_test.go new file mode 100644 index 00000000..2aa1f93a --- /dev/null +++ b/internal/controlplane/mesh_test.go @@ -0,0 +1,160 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controlplane + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "testing" + "time" + + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/peer" + + "github.com/libp2p/go-libp2p" + "google.golang.org/protobuf/proto" + + "github.com/google/sam/api" + "github.com/google/sam/internal/storage" +) + +func TestNopMeshAdapter(t *testing.T) { + adapter := NewNopMeshAdapter() + ctx := context.Background() + + if err := adapter.PublishEvent(ctx, api.MeshEvent_POLICY_UPDATE, "", nil); err != nil { + t.Fatalf("expected nil error, got %v", err) + } + + services, err := adapter.DiscoverServices(ctx, "test") + if err != nil || len(services) != 0 { + t.Fatalf("unexpected DiscoverServices output: %v, %v", services, err) + } + + status, err := adapter.GetNodeStatus(ctx, "peer1") + if err != nil || status != nil { + t.Fatalf("unexpected GetNodeStatus output: %v, %v", status, err) + } + + if err := adapter.Close(); err != nil { + t.Fatalf("expected nil error on Close, got %v", err) + } +} + +func TestP2PMeshAdapter_PublishAndSubscribe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // 1. Create Control Plane store & initial keyring + store, err := storage.NewSQLStore("sqlite", ":memory:") + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer func() { _ = store.Close() }() + + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("failed to generate key: %v", err) + } + if err := store.SaveInitialKey(ctx, priv, pub); err != nil { + t.Fatalf("failed to save key: %v", err) + } + + // 2. Create libp2p host and PubSub for Control Plane P2PMeshAdapter + cpHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatalf("failed to create cp host: %v", err) + } + defer func() { _ = cpHost.Close() }() + + cpPS, err := pubsub.NewGossipSub(ctx, cpHost) + if err != nil { + t.Fatalf("failed to create cp pubsub: %v", err) + } + + adapter, err := NewP2PMeshAdapter(cpHost, cpPS, store) + if err != nil { + t.Fatalf("failed to create P2PMeshAdapter: %v", err) + } + defer func() { _ = adapter.Close() }() + + // 3. Create subscriber node host & PubSub + subHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatalf("failed to create sub host: %v", err) + } + defer func() { _ = subHost.Close() }() + + subPS, err := pubsub.NewGossipSub(ctx, subHost) + if err != nil { + t.Fatalf("failed to create sub pubsub: %v", err) + } + + subTopic, err := subPS.Join(api.GossipEvents) + if err != nil { + t.Fatalf("failed to join topic: %v", err) + } + sub, err := subTopic.Subscribe() + if err != nil { + t.Fatalf("failed to subscribe to topic: %v", err) + } + + // 4. Connect subscriber node to Control Plane host + subHost.Peerstore().AddAddrs(cpHost.ID(), cpHost.Addrs(), 10*time.Second) + if err := subHost.Connect(ctx, peer.AddrInfo{ID: cpHost.ID(), Addrs: cpHost.Addrs()}); err != nil { + t.Fatalf("failed to connect subHost to cpHost: %v", err) + } + + // Allow GossipSub mesh overlay connection to settle + time.Sleep(500 * time.Millisecond) + + // 5. Test publishing BANNED event + targetPeerID := "12D3KooWBannedPeerIDForTesting1234567890" + if err := adapter.PublishEvent(ctx, api.MeshEvent_BANNED, targetPeerID, nil); err != nil { + t.Fatalf("failed to publish BANNED event: %v", err) + } + + msg, err := sub.Next(ctx) + if err != nil { + t.Fatalf("failed to receive event on subscriber: %v", err) + } + + var event api.MeshEvent + if err := proto.Unmarshal(msg.Data, &event); err != nil { + t.Fatalf("failed to unmarshal received MeshEvent: %v", err) + } + + if event.Type != api.MeshEvent_BANNED { + t.Fatalf("expected event type %v, got %v", api.MeshEvent_BANNED, event.Type) + } + if event.PeerId != targetPeerID { + t.Fatalf("expected peerID %q, got %q", targetPeerID, event.PeerId) + } + if len(event.Signature) == 0 { + t.Fatalf("expected signed event signature, got empty signature") + } + + // Verify Ed25519 signature against Control Plane public key + sig := event.Signature + event.Signature = nil + eventData, err := proto.Marshal(&event) + if err != nil { + t.Fatalf("failed to marshal event for sig verification: %v", err) + } + if !ed25519.Verify(pub, eventData, sig) { + t.Fatalf("ed25519 signature verification failed for published MeshEvent") + } +} diff --git a/internal/controlplane/policy.go b/internal/controlplane/policy.go deleted file mode 100644 index f1964797..00000000 --- a/internal/controlplane/policy.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2026 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package controlplane - -import ( - "fmt" - "os" - "strings" - - "github.com/biscuit-auth/biscuit-go/v2/parser" - "github.com/google/sam/api" - "gopkg.in/yaml.v2" -) - -// LoadPolicyConfig loads the policy configuration from the specified path. -// If the file is missing, it returns an empty initialized config. -func LoadPolicyConfig(path string) (*api.PolicyConfig, error) { - if _, err := os.Stat(path); os.IsNotExist(err) { - return &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{}, - Roles: make(map[string]api.RolePolicy), - }, nil - } - - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - var config api.PolicyConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, err - } - - for role, rolePolicy := range config.Roles { - for _, customFact := range rolePolicy.CustomDatalog { - trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";") - _, err := parser.FromStringFact(trimmed) - if err != nil { - return nil, fmt.Errorf("invalid custom datalog fact for role %q: %w", role, err) - } - } - } - - if err := ValidatePolicyConfig(&config); err != nil { - return nil, err - } - - return &config, nil -} - -// ValidatePolicyConfig ensures that no wildcards are used in policies, and that all referenced roles in bindings exist. -func ValidatePolicyConfig(config *api.PolicyConfig) error { - for _, b := range config.Bindings { - if len(b.Members) == 0 { - return fmt.Errorf("binding must specify at least one member") - } - - for _, member := range b.Members { - if member == api.SystemAuthenticated { - continue - } - parts := strings.SplitN(member, ":", 2) - if len(parts) != 2 { - return fmt.Errorf("member %q is invalid, must be in format 'type:value' or '%s'", member, api.SystemAuthenticated) - } - prefix := parts[0] - if _, validPrefix := api.ValidMemberPrefixes[prefix]; !validPrefix { - return fmt.Errorf("member prefix %q is invalid, expected one of the standard identity facts (e.g., user, group, email, node)", prefix) - } - } - - if b.Role == "" { - return fmt.Errorf("binding role cannot be empty") - } - if _, exists := config.Roles[b.Role]; !exists { - return fmt.Errorf("binding role %q does not exist in defined roles", b.Role) - } - } - - for role, rolePolicy := range config.Roles { - for _, svc := range rolePolicy.AllowedServices { - if err := api.ValidateServiceFormat(svc); err != nil { - return fmt.Errorf("invalid allowed service in role %q: %w", role, err) - } - } - for _, target := range rolePolicy.AllowedTargets { - if err := api.ValidateTargetFormat(target); err != nil { - return fmt.Errorf("invalid allowed target in role %q: %w", role, err) - } - } - } - return nil -} diff --git a/internal/controlplane/server.go b/internal/controlplane/server.go index d26be642..9561f71e 100644 --- a/internal/controlplane/server.go +++ b/internal/controlplane/server.go @@ -34,6 +34,7 @@ import ( "time" "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/parser" "github.com/coreos/go-oidc/v3/oidc" jwt "github.com/golang-jwt/jwt/v5" "github.com/google/sam/api" @@ -43,8 +44,8 @@ import ( "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" "golang.org/x/time/rate" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" - "gopkg.in/yaml.v2" ) var logger = golog.Logger("sam-control-plane") @@ -63,6 +64,9 @@ type Server struct { listener net.Listener limiter *rate.Limiter + meshMu sync.RWMutex + mesh MeshAdapter + providersMu sync.RWMutex providers map[string]*oidc.Provider @@ -84,6 +88,7 @@ func NewServer(config Options, store storage.Store) (*Server, error) { return &Server{ config: config, store: store, + mesh: NewNopMeshAdapter(), limiter: rate.NewLimiter(rate.Limit(EnrollRateLimit), EnrollBurst), providers: make(map[string]*oidc.Provider), ctx: ctx, @@ -91,6 +96,21 @@ func NewServer(config Options, store storage.Store) (*Server, error) { }, nil } +// SetMeshAdapter sets a custom MeshAdapter implementation for the control plane. +func (s *Server) SetMeshAdapter(m MeshAdapter) { + if m != nil { + s.meshMu.Lock() + s.mesh = m + s.meshMu.Unlock() + } +} + +func (s *Server) getMeshAdapter() MeshAdapter { + s.meshMu.RLock() + defer s.meshMu.RUnlock() + return s.mesh +} + // Start boots up HTTP services, sets up OIDC providers, loads initial keys and policies, and schedules rotations. func (s *Server) Start() error { // Initialize Keyring @@ -109,20 +129,7 @@ func (s *Server) Start() error { return fmt.Errorf("failed to query initial keyring status: %w", err) } - // Bootstrap Policy from file if DB is empty and path is provided - if s.config.PolicyPath != "" { - _, err := s.store.GetPolicy(ctx) - if err == storage.ErrNotFound { - logger.Infof("Bootstrapping mesh policy from %s...", s.config.PolicyPath) - policy, err := loadPolicyFromFile(s.config.PolicyPath) - if err != nil { - return fmt.Errorf("failed to load boot policy file: %w", err) - } - if err := s.store.SavePolicy(ctx, policy); err != nil { - return fmt.Errorf("failed to save boot policy: %w", err) - } - } - } + // Bootstrap Policy is now disabled; starting default closed. // Initialize OIDC Providers if err := s.discoverProviders(); err != nil { @@ -234,6 +241,9 @@ func (s *Server) runKeyRotationLoop() { logger.Errorf("Failed to rotate keyring: %v", err) } else { logger.Infof("Key rotation committed. New current public key: %s", hex.EncodeToString(newPub)) + if err := s.getMeshAdapter().PublishEvent(s.ctx, api.MeshEvent_KEY_ROTATION, "", newPub); err != nil { + logger.Warnf("Failed to publish KEY_ROTATION event to mesh: %v", err) + } } case <-s.ctx.Done(): return @@ -333,13 +343,7 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) { return } - // Fetch mesh policy - policy, err := s.store.GetPolicy(ctx) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to retrieve mesh policy: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + // Mesh policy is distributed dynamically to the target nodes, no need to inject into token. // Fetch current signing private key privKey, pubKey, err := s.store.GetCurrentKey(ctx) @@ -354,9 +358,44 @@ func (s *Server) HandleRegister(w http.ResponseWriter, r *http.Request) { return } + policyRoles, bindings, err := s.store.GetMeshPolicy(ctx) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to load policy for registration: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + resolvedRoles := resolveRoles(pID.String(), claims, bindings) + var hasCapabilityRoles bool + var customAccessRoles []string + resolvedMap := make(map[string]bool) + for _, r := range resolvedRoles { + resolvedMap[r] = true + if strings.HasPrefix(r, "sam:role:") { + hasCapabilityRoles = true + } else if r != req.RequestedRole { + customAccessRoles = append(customAccessRoles, r) + } + } + + isAuthorized := false + if resolvedMap[req.RequestedRole] { + isAuthorized = true + } else if req.RequestedRole == api.RoleNode && !hasCapabilityRoles { + isAuthorized = true + } + + if !isAuthorized { + http.Error(w, fmt.Sprintf("requested role %q is not authorized for this identity", req.RequestedRole), http.StatusForbidden) + return + } + + finalRoles := []string{req.RequestedRole} + finalRoles = append(finalRoles, customAccessRoles...) + // Mint token biscuitExpiry := time.Now().Add(api.BiscuitTokenTTL) - biscuitData, _, err := identity.MintBiscuitToken(privKey, claims, token, pID, policy, biscuitExpiry, req.RequestedRole) + biscuitData, _, err := identity.MintBiscuitToken(privKey, claims, token, pID, biscuitExpiry, finalRoles, policyRoles) if err != nil { logger.Errorw("Biscuit minting failed", "peer_id", req.PeerId, "error", err) http.Error(w, "Failed to mint biscuit: "+err.Error(), http.StatusForbidden) @@ -469,7 +508,7 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { } // Verify current biscuit signature and extract peer ID - pID, err := identity.VerifyAndExtractPeerID(trustedKeys, currentBiscuitBytes) + pID, err := identity.VerifyAndExtractPeerID(trustedKeys, currentBiscuitBytes, s.config.BiscuitTimeout) if err != nil { logger.Warnw("Invalid biscuit presented for refresh", "error", err) http.Error(w, "Invalid biscuit: "+err.Error(), http.StatusUnauthorized) @@ -525,9 +564,9 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { return } - policy, err := s.store.GetPolicy(ctx) + policyRoles, bindings, err := s.store.GetMeshPolicy(ctx) if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to retrieve mesh policy: %v", err) + logger.Errorf("Failed to load policy for node refresh: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } @@ -542,7 +581,35 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { http.Error(w, "Internal server error", http.StatusInternalServerError) return } - bBytes, _, err := identity.MintBiscuitToken(privKey, claims, nil, pID, policy, biscuitExpiry, nodeRecord.Role) + resolvedRoles := resolveRoles(pID.String(), claims, bindings) + var hasCapabilityRoles bool + var customAccessRoles []string + resolvedMap := make(map[string]bool) + for _, r := range resolvedRoles { + resolvedMap[r] = true + if strings.HasPrefix(r, "sam:role:") { + hasCapabilityRoles = true + } else if r != nodeRecord.Role { + customAccessRoles = append(customAccessRoles, r) + } + } + + isAuthorized := false + if resolvedMap[nodeRecord.Role] { + isAuthorized = true + } else if nodeRecord.Role == api.RoleNode && !hasCapabilityRoles { + isAuthorized = true + } + + if !isAuthorized { + http.Error(w, fmt.Sprintf("role %q is no longer authorized for this identity", nodeRecord.Role), http.StatusForbidden) + return + } + + finalRoles := []string{nodeRecord.Role} + finalRoles = append(finalRoles, customAccessRoles...) + + bBytes, _, err := identity.MintBiscuitToken(privKey, claims, nil, pID, biscuitExpiry, finalRoles, policyRoles) if err != nil { logger.Errorf("Failed to mint refreshed token for node %s: %v", nodeRecord.PeerID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -551,7 +618,7 @@ func (s *Server) HandleRefresh(w http.ResponseWriter, r *http.Request) { biscuitBytes = bBytes } else { // Bootstrap node - bBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, nodeRecord.Role, biscuitExpiry, policy) + bBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, nodeRecord.Role, biscuitExpiry, policyRoles) if err != nil { logger.Errorf("Failed to mint refreshed token for node %s: %v", nodeRecord.PeerID, err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -724,36 +791,66 @@ func (s *Server) HandleRouterLease(w http.ResponseWriter, r *http.Request) { // HandlePolicies HTTP GET/POST/PUT `/policies` func (s *Server) HandlePolicies(w http.ResponseWriter, r *http.Request) { - if !s.checkAdminAuth(w, r) { - return - } // Simple HTTP admin methods for policies switch r.Method { case http.MethodGet: - policy, err := s.store.GetPolicy(r.Context()) - if err == storage.ErrNotFound { - http.Error(w, "No policy configured", http.StatusNotFound) + // Nodes need to fetch policies using their Biscuit token, Admins use OIDC/Bootstrap + isAdmin := false + user, err := s.authenticateUser(r) + if err == nil && user.Role == "admin" { + isAdmin = true + } + + isNode := false + if !isAdmin { + // Try checking if it's a valid node biscuit + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + biscuitBytes, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHeader, "Bearer ")) + if err == nil { + validKeys, err := s.store.GetAllValidKeys(r.Context()) + if err == nil { + var trustedKeys []ed25519.PublicKey + for _, k := range validKeys { + trustedKeys = append(trustedKeys, k.Public) + } + peerID, err := identity.VerifyAndExtractPeerID(trustedKeys, biscuitBytes, s.config.BiscuitTimeout) + if err == nil { + nodeRecord, nodeErr := s.store.GetNode(r.Context(), peerID.String()) + if nodeErr == nil && nodeRecord != nil && !nodeRecord.Banned { + isNode = true + } + } + } + } + } + } + + if !isAdmin && !isNode { + http.Error(w, "Unauthorized: Admin or Node authentication required", http.StatusUnauthorized) return } - if err != nil { + + roles, bindings, err := s.store.GetMeshPolicy(r.Context()) + if err != nil && err != storage.ErrNotFound { logger.Errorf("Failed to load policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } - yamlData, err := yaml.Marshal(policy) - if err != nil { - http.Error(w, "Failed to marshal yaml", http.StatusInternalServerError) - return + resp := &api.PolicyConfigGetResponse{ + Roles: roles, + Bindings: bindings, } - - resp := &api.PolicyConfigGetResponse{YamlContent: string(yamlData)} respData, _ := proto.Marshal(resp) w.Header().Set("Content-Type", "application/x-protobuf") w.WriteHeader(http.StatusOK) _, _ = w.Write(respData) case http.MethodPost, http.MethodPut: + if !s.checkAdminAuth(w, r) { + return + } body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) @@ -762,29 +859,34 @@ func (s *Server) HandlePolicies(w http.ResponseWriter, r *http.Request) { defer func() { _ = r.Body.Close() }() var req api.PolicyConfigUpdateRequest - if err := proto.Unmarshal(body, &req); err != nil { - http.Error(w, "Invalid request format", http.StatusBadRequest) - return - } - - var policy api.PolicyConfig - if err := yaml.Unmarshal([]byte(req.YamlContent), &policy); err != nil { - http.Error(w, "Invalid YAML policy format: "+err.Error(), http.StatusBadRequest) - return + if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { + unmarshaler := protojson.UnmarshalOptions{DiscardUnknown: true} + if err := unmarshaler.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON format: "+err.Error(), http.StatusBadRequest) + return + } + } else { + if err := proto.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid request format", http.StatusBadRequest) + return + } } - // Run validation similar to Hub config loading - if err := ValidatePolicyConfig(&policy); err != nil { - http.Error(w, "Invalid policy structure: "+err.Error(), http.StatusBadRequest) + if err := validatePolicyConfig(&req); err != nil { + http.Error(w, "Invalid policy configuration: "+err.Error(), http.StatusBadRequest) return } - if err := s.store.SavePolicy(r.Context(), &policy); err != nil { + if err := s.store.SaveMeshPolicy(r.Context(), req.Roles, req.Bindings); err != nil { logger.Errorf("Failed to save policy: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) return } + if err := s.getMeshAdapter().PublishEvent(r.Context(), api.MeshEvent_POLICY_UPDATE, "", nil); err != nil { + logger.Warnf("Failed to publish POLICY_UPDATE event to mesh: %v", err) + } + resp := &api.PolicyConfigUpdateResponse{Success: true} respData, _ := proto.Marshal(resp) w.Header().Set("Content-Type", "application/x-protobuf") @@ -811,11 +913,6 @@ func (s *Server) Close() error { return errors.Join(errs...) } -func loadPolicyFromFile(path string) (*api.PolicyConfig, error) { - // Reused load script from config.go - return LoadPolicyConfig(path) -} - // Addr returns the network address the server is listening on. func (s *Server) Addr() string { if s.listener == nil { @@ -953,13 +1050,7 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) { CreatedAt: time.Now(), } - // Fetch mesh policy - policy, err := s.store.GetPolicy(ctx) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to retrieve policy: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + // No policy needed in token. // Fetch current signing private key privKey, _, err := s.store.GetCurrentKey(ctx) @@ -971,7 +1062,13 @@ func (s *Server) HandleEnroll(w http.ResponseWriter, r *http.Request) { if s.config.AutoApproveEnrollment { // Mode A: Auto-Approve - biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policy) + policyRoles, _, err := s.store.GetMeshPolicy(ctx) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to retrieve mesh policy: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policyRoles) if err != nil { logger.Errorf("Failed to mint bootstrap biscuit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1304,12 +1401,7 @@ func (s *Server) HandleAdminEnrollmentAction(w http.ResponseWriter, r *http.Requ return } - policy, err := s.store.GetPolicy(ctx) - if err != nil && err != storage.ErrNotFound { - logger.Errorf("Failed to retrieve policy: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) - return - } + // No policy fetch needed. privKey, _, err := s.store.GetCurrentKey(ctx) if err != nil { @@ -1318,7 +1410,13 @@ func (s *Server) HandleAdminEnrollmentAction(w http.ResponseWriter, r *http.Requ return } - biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policy) + policyRoles, _, err := s.store.GetMeshPolicy(ctx) + if err != nil && err != storage.ErrNotFound { + logger.Errorf("Failed to retrieve mesh policy: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + biscuitBytes, err := identity.MintBootstrapBiscuitToken(privKey, pID, tokenRecord.Role, time.Now().Add(api.BiscuitTokenTTL), policyRoles) if err != nil { logger.Errorf("Failed to mint bootstrap biscuit: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -1406,6 +1504,10 @@ func (s *Server) HandleAdminRevoke(w http.ResponseWriter, r *http.Request) { return } + if err := s.getMeshAdapter().PublishEvent(ctx, api.MeshEvent_BANNED, req.PeerId, nil); err != nil { + logger.Warnf("Failed to publish BANNED event for node %s to mesh: %v", req.PeerId, err) + } + resp := &api.TokenRevokeResponse{ Success: true, } @@ -1503,14 +1605,7 @@ func (s *Server) HandleUserStatus(w http.ResponseWriter, r *http.Request) { return } - var policyYAML string - policy, err := s.store.GetPolicy(ctx) - if err == nil { - yamlData, err := yaml.Marshal(policy) - if err == nil { - policyYAML = string(yamlData) - } - } + var policyYAML string // TODO: Implement relational policy rendering. resp := map[string]any{ "user": map[string]any{ @@ -1647,6 +1742,168 @@ func (s *Server) HandleUserRevoke(w http.ResponseWriter, r *http.Request) { return } + if err := s.getMeshAdapter().PublishEvent(ctx, api.MeshEvent_BANNED, peerID, nil); err != nil { + logger.Warnf("Failed to publish BANNED event for node %s to mesh: %v", peerID, err) + } + w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("Node revoked successfully")) } + +func resolveRoles(peerID string, claims jwt.MapClaims, bindings []*api.PolicyBinding) []string { + if claims == nil { + claims = make(jwt.MapClaims) + } + oidcRoles := toStringSlice(claims["roles"]) + oidcGroups := toStringSlice(claims["groups"]) + oidcSub, _ := claims["sub"].(string) + oidcEmail, _ := claims["email"].(string) + + resolvedRoles := make(map[string]bool) + for _, b := range bindings { + if b == nil { + continue + } + for _, m := range b.Members { + if m == api.SystemAuthenticated { + resolvedRoles[b.Role] = true + continue + } + parts := strings.SplitN(m, ":", 2) + if len(parts) != 2 { + continue + } + prefix, value := parts[0], parts[1] + switch prefix { + case api.FactNode: + if peerID == value { + resolvedRoles[b.Role] = true + } + case api.FactGroup: + for _, g := range oidcGroups { + if g == value { + resolvedRoles[b.Role] = true + } + } + case api.FactUser: + if oidcSub == value { + resolvedRoles[b.Role] = true + } + case api.FactEmail: + if oidcEmail == value { + resolvedRoles[b.Role] = true + } + case api.FactRole: + for _, r := range oidcRoles { + if r == value { + resolvedRoles[b.Role] = true + } + } + } + } + } + + var res []string + for r := range resolvedRoles { + res = append(res, r) + } + return res +} + +func toStringSlice(val any) []string { + if val == nil { + return nil + } + switch v := val.(type) { + case string: + if v != "" { + return []string{v} + } + case []string: + return v + case []any: + var res []string + for _, item := range v { + if str, ok := item.(string); ok && str != "" { + res = append(res, str) + } + } + return res + } + return nil +} + +func validatePolicyConfig(req *api.PolicyConfigUpdateRequest) error { + roleNames := make(map[string]bool) + for _, r := range req.Roles { + if r == nil { + continue + } + if strings.TrimSpace(r.Name) == "" { + return fmt.Errorf("role name cannot be empty") + } + if roleNames[r.Name] { + return fmt.Errorf("duplicate role name: %s", r.Name) + } + roleNames[r.Name] = true + + for _, svc := range r.AllowedServices { + if err := api.ValidateServiceFormat(svc); err != nil { + return fmt.Errorf("invalid allowed_service %q in role %s: %w", svc, r.Name, err) + } + } + for _, target := range r.AllowedTargets { + if err := api.ValidateTargetFormat(target); err != nil { + return fmt.Errorf("invalid allowed_target %q in role %s: %w", target, r.Name, err) + } + } + for _, dl := range r.CustomDatalog { + trimmed := strings.TrimRight(strings.TrimSpace(dl), ";") + if trimmed == "" { + continue + } + if _, err := parser.FromStringRule(trimmed); err != nil { + if _, err := parser.FromStringFact(trimmed); err != nil { + return fmt.Errorf("invalid custom datalog %q in role %s: %w", dl, r.Name, err) + } + } + } + } + + validPrefixes := map[string]bool{ + api.FactNode: true, + api.FactGroup: true, + api.FactUser: true, + api.FactEmail: true, + api.FactRole: true, + } + + for _, b := range req.Bindings { + if b == nil { + continue + } + if strings.TrimSpace(b.Role) == "" { + return fmt.Errorf("binding role cannot be empty") + } + if !roleNames[b.Role] { + return fmt.Errorf("binding references undefined role: %s", b.Role) + } + if len(b.Members) == 0 { + return fmt.Errorf("binding for role %q must specify at least one member", b.Role) + } + for _, member := range b.Members { + if member == api.SystemAuthenticated { + continue + } + parts := strings.SplitN(member, ":", 2) + if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" { + return fmt.Errorf("member %q in binding for role %q is invalid, must be in format 'type:value' or %q", member, b.Role, api.SystemAuthenticated) + } + prefix := parts[0] + if !validPrefixes[prefix] { + return fmt.Errorf("member prefix %q in member %q is invalid", prefix, member) + } + } + } + return nil +} diff --git a/internal/controlplane/server_test.go b/internal/controlplane/server_test.go index 7d561e3e..fb3bc7c5 100644 --- a/internal/controlplane/server_test.go +++ b/internal/controlplane/server_test.go @@ -29,11 +29,11 @@ import ( "os" "path/filepath" "reflect" - "strings" "testing" "time" "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/datalog" "github.com/golang-jwt/jwt/v5" "github.com/google/sam/api" "github.com/google/sam/internal/node" @@ -214,23 +214,22 @@ func TestNodeAndRouterRegistrationFlow(t *testing.T) { ctx := context.Background() // 1. Setup policy configuration in the database - policy := &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{ - {Role: api.RoleRouter, Members: []string{"group:routers"}}, - {Role: "user-role", Members: []string{"group:users"}}, + roles := []*api.PolicyRole{ + { + Name: api.RoleRouter, + AllowedServices: []string{"*"}, + AllowedTargets: []string{"*"}, }, - Roles: map[string]api.RolePolicy{ - api.RoleRouter: { - AllowedServices: []string{"*"}, - AllowedTargets: []string{"*"}, - }, - "user-role": { - AllowedServices: []string{"mcp:read"}, - }, + { + Name: "user-role", + AllowedServices: []string{"mcp:read"}, }, } - if err := store.SavePolicy(ctx, policy); err != nil { + bindings := []*api.PolicyBinding{ + {Role: api.RoleRouter, Members: []string{"group:routers"}}, + {Role: "user-role", Members: []string{"group:users"}}, + } + if err := store.SaveMeshPolicy(ctx, roles, bindings); err != nil { t.Fatalf("failed to seed policy: %v", err) } @@ -292,6 +291,7 @@ func TestNodeAndRouterRegistrationFlow(t *testing.T) { routerJWT := mintToken(map[string]interface{}{ "sub": "router-host-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) routerPubKeyBytes, _ := crypto.MarshalPublicKey(privRouter.GetPublic()) @@ -422,24 +422,23 @@ func TestPoliciesConfigurationREST(t *testing.T) { if err != nil { t.Fatalf("GET /policies failed: %v", err) } - if resp.StatusCode != http.StatusNotFound { - t.Errorf("expected 404 for missing policy, got %d", resp.StatusCode) + if resp.StatusCode != http.StatusOK { + t.Errorf("expected 200 for empty policy, got %d", resp.StatusCode) } _ = resp.Body.Close() // 4. Put policies (should succeed) - newPolicyYaml := ` -version: v1alpha1 -bindings: - - role: dev - members: - - group:developers -roles: - dev: - allowed_services: - - mcp://git -` - updateReq := &api.PolicyConfigUpdateRequest{YamlContent: newPolicyYaml} + updateReq := &api.PolicyConfigUpdateRequest{ + Roles: []*api.PolicyRole{ + { + Name: "dev", + AllowedServices: []string{"mcp://git"}, + }, + }, + Bindings: []*api.PolicyBinding{ + {Role: "dev", Members: []string{"group:developers"}}, + }, + } reqData, _ := proto.Marshal(updateReq) req, _ = http.NewRequest(http.MethodPost, baseURL+"/policies", bytes.NewReader(reqData)) @@ -473,8 +472,11 @@ roles: t.Fatalf("failed to unmarshal PolicyConfigGetResponse: %v", err) } - if !strings.Contains(getResp.YamlContent, "role: dev") || !strings.Contains(getResp.YamlContent, "group:developers") { - t.Errorf("returned policy content mismatch: %s", getResp.YamlContent) + if len(getResp.Roles) == 0 || getResp.Roles[0].Name != "dev" { + t.Errorf("returned policy content mismatch: %+v", getResp.Roles) + } + if len(getResp.Bindings) == 0 || getResp.Bindings[0].Members[0] != "group:developers" { + t.Errorf("returned policy content mismatch: %+v", getResp.Bindings) } } @@ -630,7 +632,7 @@ func TestEnrollmentWorkflow(t *testing.T) { if err != nil { t.Fatalf("failed to unmarshal biscuit: %v", err) } - authorizer, err := b.Authorizer(cpPub) + authorizer, err := b.Authorizer(cpPub, biscuit.WithWorldOptions(datalog.WithMaxDuration(srv.config.BiscuitTimeout))) if err != nil { t.Fatal(err) } @@ -698,13 +700,11 @@ func TestTokenRefreshAndRevocation(t *testing.T) { ctx := context.Background() // Setup policy configuration in the database - policy := &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{ - {Role: api.RoleNode, Members: []string{"group:users"}}, - }, + roles2 := []*api.PolicyRole{} + bindings2 := []*api.PolicyBinding{ + {Role: api.RoleNode, Members: []string{"group:users"}}, } - if err := store.SavePolicy(ctx, policy); err != nil { + if err := store.SaveMeshPolicy(ctx, roles2, bindings2); err != nil { t.Fatal(err) } @@ -835,13 +835,11 @@ func TestNodeProactiveTokenRefresh(t *testing.T) { ctx := context.Background() // Setup policy configuration in the database - policy := &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{ - {Role: api.RoleNode, Members: []string{"group:users"}}, - }, + roles3 := []*api.PolicyRole{} + bindings3 := []*api.PolicyBinding{ + {Role: api.RoleNode, Members: []string{"group:users"}}, } - if err := store.SavePolicy(ctx, policy); err != nil { + if err := store.SaveMeshPolicy(ctx, roles3, bindings3); err != nil { t.Fatal(err) } @@ -1125,3 +1123,79 @@ func TestUserStatusAndTenancy(t *testing.T) { t.Error("expected node A to be banned/revoked in DB") } } + +func TestResolveRolesAndRoleImpersonationProtection(t *testing.T) { + bindings := []*api.PolicyBinding{ + { + Role: api.RoleRouter, + Members: []string{"group:routers", "role:oidc-router-role"}, + }, + { + Role: api.RoleSamBox, + Members: []string{"user:sambox-admin-sub"}, + }, + } + + t.Run("OIDC claims role is not blindly trusted without explicit binding", func(t *testing.T) { + claims := jwt.MapClaims{ + "sub": "attacker-sub", + "roles": []string{api.RoleRouter, api.RoleSamBox, "unbound-role"}, + } + roles := resolveRoles("peer-123", claims, bindings) + for _, r := range roles { + if r == api.RoleRouter || r == api.RoleSamBox { + t.Errorf("Security flaw: resolveRoles granted capability role %q from raw OIDC claims without explicit binding", r) + } + } + }) + + t.Run("Explicit role mapping in binding grants capability role", func(t *testing.T) { + claims := jwt.MapClaims{ + "sub": "router-sub", + "roles": []string{"oidc-router-role"}, + } + roles := resolveRoles("peer-123", claims, bindings) + hasRouter := false + for _, r := range roles { + if r == api.RoleRouter { + hasRouter = true + } + } + if !hasRouter { + t.Errorf("Expected role %q to be granted via explicit role binding mapping", api.RoleRouter) + } + }) + + t.Run("Group membership grants bound capability role", func(t *testing.T) { + claims := jwt.MapClaims{ + "sub": "router-user", + "groups": []string{"routers"}, + } + roles := resolveRoles("peer-456", claims, bindings) + hasRouter := false + for _, r := range roles { + if r == api.RoleRouter { + hasRouter = true + } + } + if !hasRouter { + t.Errorf("Expected role %q to be granted via group binding", api.RoleRouter) + } + }) + + t.Run("User sub grants bound capability role", func(t *testing.T) { + claims := jwt.MapClaims{ + "sub": "sambox-admin-sub", + } + roles := resolveRoles("peer-789", claims, bindings) + hasSamBox := false + for _, r := range roles { + if r == api.RoleSamBox { + hasSamBox = true + } + } + if !hasSamBox { + t.Errorf("Expected role %q to be granted via user sub binding", api.RoleSamBox) + } + }) +} diff --git a/internal/controlplane/ui.go b/internal/controlplane/ui.go index 87e7fd7c..e56d928b 100644 --- a/internal/controlplane/ui.go +++ b/internal/controlplane/ui.go @@ -62,13 +62,41 @@ func (s *Server) HandleAdminStatus(w http.ResponseWriter, r *http.Request) { return } + roles, bindings, err := s.store.GetMeshPolicy(r.Context()) + if err != nil { + logger.Errorf("Failed to list policy: %v", err) + } + + type displayRole struct { + AllowedServices []string `yaml:"allowed_services"` + AllowedTargets []string `yaml:"allowed_targets"` + } + type displayBinding struct { + Role string `yaml:"role"` + Members []string `yaml:"members"` + } + displayMap := map[string]interface{}{ + "roles": make(map[string]displayRole), + "bindings": make([]displayBinding, 0), + } + + for _, role := range roles { + displayMap["roles"].(map[string]displayRole)[role.Name] = displayRole{ + AllowedServices: role.AllowedServices, + AllowedTargets: role.AllowedTargets, + } + } + for _, b := range bindings { + displayMap["bindings"] = append(displayMap["bindings"].([]displayBinding), displayBinding{ + Role: b.Role, + Members: b.Members, + }) + } + var policyYAML string - policy, err := s.store.GetPolicy(ctx) + yamlBytes, err := yaml.Marshal(displayMap) if err == nil { - yamlData, err := yaml.Marshal(policy) - if err == nil { - policyYAML = string(yamlData) - } + policyYAML = string(yamlBytes) } resp := map[string]any{ diff --git a/internal/identity/biscuit.go b/internal/identity/biscuit.go index b6d1eb5c..a728a177 100644 --- a/internal/identity/biscuit.go +++ b/internal/identity/biscuit.go @@ -32,118 +32,19 @@ import ( ) // MintBiscuitToken generates a signed Biscuit token for a peer with policy rules based on JWT claims. -// -// Role Authorization Rationale: -// The system has two distinct kinds of roles: -// 1. Capability Roles (prefixed with "sam:role:"): These authorize the client's binary startup role -// (e.g., sam:role:node, sam:role:router, sam:role:sambox) under Zero Trust. -// 2. Custom Access Roles (all other roles): These define authorization permissions for custom -// services and targets (e.g., restricted-role). -// -// When enrolling: -// - The client binary must explicitly request a single capability role (requestedRole). -// - We verify if the client's OIDC identity is authorized to claim that requested capability role: -// - If the user has explicitly mapped capability roles (from policy/claims), they must only request one of those. -// - If the user has no capability roles mapped, they are allowed to request the standard fallback role ("sam:role:node"). -// - Once authorized, the generated Biscuit is minted with: -// - The requested capability role (enforcing least privilege). -// - All other custom access roles mapped to the identity (so they preserve their resource permissions). -func MintBiscuitToken(signingKey ed25519.PrivateKey, claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID, policy *api.PolicyConfig, biscuitExpiry time.Time, requestedRole string) ([]byte, []string, error) { +func MintBiscuitToken(signingKey ed25519.PrivateKey, claims jwt.MapClaims, token *oidc.IDToken, remotePeer peer.ID, biscuitExpiry time.Time, roles []string, policyRoles []*api.PolicyRole) ([]byte, []string, error) { if claims == nil { return nil, nil, fmt.Errorf("claims cannot be nil") } - if requestedRole == "" { - return nil, nil, fmt.Errorf("requested_role must be specified") - } - - oidcRoles := toStringSlice(claims["roles"]) - oidcGroups := toStringSlice(claims["groups"]) - oidcSub, _ := claims["sub"].(string) - oidcEmail, _ := claims["email"].(string) - - // Resolve all roles assigned to this identity by policy bindings or direct OIDC roles. - resolvedRoles := make(map[string]bool) - if policy != nil { - for _, b := range policy.Bindings { - for _, member := range b.Members { - if member == api.SystemAuthenticated { - resolvedRoles[b.Role] = true - break - } - - parts := strings.SplitN(member, ":", 2) - if len(parts) != 2 { - continue - } - prefix, value := parts[0], parts[1] - - switch prefix { - case api.FactGroup: - for _, cg := range oidcGroups { - if value == cg { - resolvedRoles[b.Role] = true - } - } - case api.FactUser: - if oidcSub != "" && value == oidcSub { - resolvedRoles[b.Role] = true - } - case api.FactEmail: - if oidcEmail != "" && value == oidcEmail { - resolvedRoles[b.Role] = true - } - case api.FactNode: - if value == remotePeer.String() { - resolvedRoles[b.Role] = true - } - } - } - } - - for _, r := range oidcRoles { - if _, exists := policy.Roles[r]; exists { - resolvedRoles[r] = true - } - } - } - - // Categorize resolved roles into Capability roles (sam:role:*) and Custom Access roles. - var hasCapabilityRoles bool - var customAccessRoles []string - - for r := range resolvedRoles { - if strings.HasPrefix(r, "sam:role:") { - hasCapabilityRoles = true - } else if r != requestedRole { - customAccessRoles = append(customAccessRoles, r) - } - } - - // Verify that the requested capability role is authorized. - isAuthorized := false - if resolvedRoles[requestedRole] { - isAuthorized = true - } else if requestedRole == api.RoleNode && !hasCapabilityRoles { - // Default fallback capability if no capability roles are explicitly assigned. - isAuthorized = true - } - - if !isAuthorized { - return nil, nil, fmt.Errorf("requested role %q is not authorized for this identity", requestedRole) - } - - // Assemble final roles: the single requested capability role + all custom access roles. - finalRoles := []string{requestedRole} - finalRoles = append(finalRoles, customAccessRoles...) - biscuitBytes, err := mintBiscuit(signingKey, remotePeer, finalRoles, biscuitExpiry, policy, claims) + biscuitBytes, err := mintBiscuit(signingKey, remotePeer, roles, biscuitExpiry, claims, policyRoles) if err != nil { return nil, nil, err } - return biscuitBytes, finalRoles, nil + return biscuitBytes, roles, nil } -func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []string, expiration time.Time, policy *api.PolicyConfig, claims jwt.MapClaims) ([]byte, error) { +func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []string, expiration time.Time, claims jwt.MapClaims, policyRoles []*api.PolicyRole) ([]byte, error) { builder := biscuit.NewBuilder(signingKey) addedFacts := make(map[string]bool) addFact := func(fact biscuit.Fact) error { @@ -185,6 +86,15 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri } } + rolesMap := make(map[string]*api.PolicyRole) + for _, pr := range policyRoles { + if pr != nil { + rolesMap[pr.Name] = pr + } + } + + hasTargets := false + hasServices := false sort.Strings(roles) var errs []error for _, role := range roles { @@ -209,144 +119,59 @@ func mintBiscuit(signingKey ed25519.PrivateKey, remotePeer peer.ID, roles []stri }}); err != nil { errs = append(errs, fmt.Errorf("failed to add target unrestricted: %w", err)) } + hasTargets = true + hasServices = true continue } - if policy != nil { - if rolePolicy, ok := policy.Roles[role]; ok { - for _, svc := range rolePolicy.AllowedServices { - svcType, svcName := api.ParseServiceTarget(svc) - - if svcType == "*" && svcName == "*" { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedServiceAllTypes, - IDs: []biscuit.Term{}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_service_all_types fact: %w", err)) - } - } else if svcName == "*" { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedServiceAll, - IDs: []biscuit.Term{biscuit.String(svcType)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_service_all fact: %w", err)) - } - } else if strings.HasPrefix(svcName, "*.") { - suffix := svcName[1:] - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedServiceSuffix, - IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(suffix)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_service_suffix fact: %w", err)) - } - } else if strings.HasSuffix(svcName, ".*") { - prefix := svcName[:len(svcName)-1] - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedServicePrefix, - IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(prefix)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_service_prefix fact: %w", err)) - } - } else { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedServiceExact, - IDs: []biscuit.Term{biscuit.String(svcType), biscuit.String(svcName)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_service_exact fact: %w", err)) - } + if pr, ok := rolesMap[role]; ok { + if len(pr.AllowedServices) > 0 { + hasServices = true + for _, svc := range pr.AllowedServices { + if err := addFact(api.BuildServiceDatalogFact(svc)); err != nil { + errs = append(errs, fmt.Errorf("failed to add service fact %q for role %s: %w", svc, role, err)) } } + } - for _, target := range rolePolicy.AllowedTargets { - targetFact, targetVal := api.ParseServiceTarget(target) - - if targetFact == "*" && targetVal == "*" { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedTargetAllFacts, - IDs: []biscuit.Term{}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_target_all_facts fact: %w", err)) - } - } else if targetVal == "*" { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedTargetAll, - IDs: []biscuit.Term{biscuit.String(targetFact)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_target_all fact: %w", err)) - } - } else if strings.HasPrefix(targetVal, "*.") { - suffix := targetVal[1:] - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedTargetSuffix, - IDs: []biscuit.Term{biscuit.String(targetFact), biscuit.String(suffix)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_target_suffix fact: %w", err)) - } - } else if strings.HasSuffix(targetVal, ".*") { - prefix := targetVal[:len(targetVal)-1] - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedTargetPrefix, - IDs: []biscuit.Term{biscuit.String(targetFact), biscuit.String(prefix)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_target_prefix fact: %w", err)) - } - } else { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: api.FactGrantedTargetExact, - IDs: []biscuit.Term{biscuit.String(targetFact), biscuit.String(targetVal)}, - }}); err != nil { - errs = append(errs, fmt.Errorf("failed to add granted_target_exact fact: %w", err)) - } + if len(pr.AllowedTargets) > 0 { + hasTargets = true + for _, target := range pr.AllowedTargets { + if err := addFact(api.BuildTargetDatalogFact(target)); err != nil { + errs = append(errs, fmt.Errorf("failed to add target fact %q for role %s: %w", target, role, err)) } } + } - for _, customFact := range rolePolicy.CustomDatalog { - trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";") - if trimmed == "" { - continue - } - var factErr error - func() { - defer func() { - if r := recover(); r != nil { - factErr = fmt.Errorf("panic parsing custom fact %q: %v", trimmed, r) - } - }() - fact, err := parser.FromStringFact(trimmed) - if err != nil { - factErr = fmt.Errorf("failed to parse custom fact %q: %w", trimmed, err) - return - } - if err := addFact(fact); err != nil { - factErr = fmt.Errorf("failed to add custom fact %q: %w", trimmed, err) - } - }() - if factErr != nil { - errs = append(errs, factErr) - } + for _, customFact := range pr.CustomDatalog { + trimmed := strings.TrimRight(strings.TrimSpace(customFact), ";") + if trimmed == "" { + continue + } + fact, err := parser.FromStringFact(trimmed) + if err != nil { + errs = append(errs, fmt.Errorf("failed to parse custom Datalog fact %q for role %s: %w", customFact, role, err)) + } else if err := addFact(fact); err != nil { + errs = append(errs, fmt.Errorf("failed to add custom Datalog fact %q for role %s: %w", customFact, role, err)) } } } } - hasTargets := false - for _, role := range roles { - if policy != nil { - if rolePolicy, ok := policy.Roles[role]; ok { - if len(rolePolicy.AllowedTargets) > 0 { - hasTargets = true - break - } - } + if !hasServices && len(policyRoles) == 0 { + if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactGrantedServiceAllTypes, + IDs: []biscuit.Term{}, + }}); err != nil { + errs = append(errs, fmt.Errorf("failed to add fallback service fact: %w", err)) } } - if hasTargets { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactTargetRestricted}}); err != nil { - errs = append(errs, err) - } - } else { - if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{Name: api.FactTargetUnrestricted}}); err != nil { - errs = append(errs, err) + if !hasTargets && len(policyRoles) == 0 { + if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ + Name: api.FactTargetUnrestricted, + IDs: []biscuit.Term{}, + }}); err != nil { + errs = append(errs, fmt.Errorf("failed to add fallback target fact: %w", err)) } } @@ -457,17 +282,17 @@ func translateClaimsToFacts(addFact func(biscuit.Fact) error, claims map[string] return fmt.Errorf("failed to add %s fact: %w", factName, err) } } - case api.FactGroup: - groups := toStringSlice(val) + case api.FactGroup, api.FactRole: + items := toStringSlice(val) seen := make(map[string]bool) - for _, g := range groups { - if seen[g] { + for _, item := range items { + if seen[item] { continue } - seen[g] = true + seen[item] = true if err := addFact(biscuit.Fact{Predicate: biscuit.Predicate{ Name: factName, - IDs: []biscuit.Term{biscuit.String(g)}, + IDs: []biscuit.Term{biscuit.String(item)}, }}); err != nil { return fmt.Errorf("failed to add %s fact: %w", factName, err) } @@ -501,31 +326,40 @@ func toStringSlice(val any) []string { } // MintBootstrapBiscuitToken generates a signed Biscuit token for a peer using a bootstrap role. -func MintBootstrapBiscuitToken(signingKey ed25519.PrivateKey, remotePeer peer.ID, role string, expiration time.Time, policy *api.PolicyConfig) ([]byte, error) { - return mintBiscuit(signingKey, remotePeer, []string{role}, expiration, policy, nil) +func MintBootstrapBiscuitToken(signingKey ed25519.PrivateKey, remotePeer peer.ID, role string, expiration time.Time, policyRoles []*api.PolicyRole) ([]byte, error) { + return mintBiscuit(signingKey, remotePeer, []string{role}, expiration, nil, policyRoles) } // VerifyAndExtractPeerID checks that the biscuit is signed by one of the trusted keys and returns the peer ID. // This function does NOT perform time checks, making it suitable for token refresh flows. -func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData []byte) (peer.ID, error) { +func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData []byte, timeout time.Duration) (peer.ID, error) { b, err := biscuit.Unmarshal(biscuitData) if err != nil { return "", fmt.Errorf("malformed biscuit: %w", err) } + var authOpts []biscuit.AuthorizerOption + if timeout > 0 { + authOpts = append(authOpts, biscuit.WithWorldOptions(datalog.WithMaxDuration(timeout))) + } + var authorizer biscuit.Authorizer var verified bool var lastErr error for _, pubKey := range trustedPublicKeys { - auth, err := b.Authorizer(pubKey) + auth, err := b.Authorizer(pubKey, authOpts...) if err != nil { lastErr = err continue } - authorizer = auth - verified = true - break + if err := auth.Authorize(); err == nil || errors.Is(err, biscuit.ErrNoMatchingPolicy) { + authorizer = auth + verified = true + break + } else { + lastErr = err + } } if !verified { @@ -538,9 +372,6 @@ func VerifyAndExtractPeerID(trustedPublicKeys []ed25519.PublicKey, biscuitData [ return "", fmt.Errorf("failed to parse query rule: %w", err) } - // Trigger datalog engine evaluation to copy token facts to authorizer world - _ = authorizer.Authorize() - facts, err := authorizer.Query(peerRule) if err != nil { return "", fmt.Errorf("query failed: %w", err) diff --git a/internal/identity/biscuit_test.go b/internal/identity/biscuit_test.go index 1bf9c05f..0987ab0a 100644 --- a/internal/identity/biscuit_test.go +++ b/internal/identity/biscuit_test.go @@ -17,7 +17,6 @@ package identity import ( "crypto/ed25519" "crypto/rand" - "strings" "sync" "testing" "time" @@ -31,165 +30,6 @@ import ( "github.com/libp2p/go-libp2p/core/peer" ) -func TestMintBiscuitToken(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - - policy := &api.PolicyConfig{ - Bindings: []api.Binding{ - {Role: "canary-role", Members: []string{"group:system:serviceaccounts:sam-canary"}}, - {Role: "canary-role", Members: []string{"user:system:serviceaccount:sam-canary:sam-node-sa"}}, - }, - Roles: map[string]api.RolePolicy{ - "admin": { - AllowedServices: []string{"mcp:read", "mcp:write"}, - AllowedTargets: []string{"mcp:target1"}, - }, - "canary-role": { - AllowedServices: []string{"mcp:1.0.0"}, - }, - }, - } - - privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) - if err != nil { - t.Fatal(err) - } - dummyPeer, err := peer.IDFromPrivateKey(privNode) - if err != nil { - t.Fatal(err) - } - - token := &oidc.IDToken{ - Expiry: time.Now().Add(1 * time.Hour), - } - - // Case 1: Direct OIDC role (valid) - claims1 := jwt.MapClaims{ - "roles": []any{"admin"}, - } - biscuitData1, _, err := MintBiscuitToken(priv, claims1, token, dummyPeer, policy, token.Expiry, "admin") - if err != nil { - t.Fatal(err) - } - - b1, err := biscuit.Unmarshal(biscuitData1) - if err != nil { - t.Fatal(err) - } - authorizer1, err := b1.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } - rule1 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("admin")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer1.AddPolicy(rule1) - authorizer1.AddFact(biscuit.Fact{Predicate: biscuit.Predicate{ - Name: "allow_network_target", - IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("target1")}, - }}) - if err := authorizer1.Authorize(); err != nil { - t.Errorf("Expected direct role 'admin' to be authorized: %v", err) - } - - // Case 2: OIDC group claim mapped to role via bindings - claims2 := jwt.MapClaims{ - "groups": []any{"system:serviceaccounts:sam-canary"}, - } - biscuitData2, _, err := MintBiscuitToken(priv, claims2, token, dummyPeer, policy, token.Expiry, "canary-role") - if err != nil { - t.Fatal(err) - } - - b2, err := biscuit.Unmarshal(biscuitData2) - if err != nil { - t.Fatal(err) - } - authorizer2, err := b2.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } - rule2 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer2.AddPolicy(rule2) - if err := authorizer2.Authorize(); err != nil { - t.Errorf("Expected mapped role 'canary-role' to be authorized: %v", err) - } - - // Case 3: Unmapped OIDC group and undefined direct role -> falls back to sam:role:node - claims3 := jwt.MapClaims{ - "groups": []any{"unknown-group"}, - "roles": []any{"undefined-role"}, - } - biscuitData3, _, err := MintBiscuitToken(priv, claims3, token, dummyPeer, policy, token.Expiry, api.RoleNode) - if err != nil { - t.Fatal(err) - } - b3, err := biscuit.Unmarshal(biscuitData3) - if err != nil { - t.Fatal(err) - } - authorizer3, err := b3.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } - rule3 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String(api.RoleNode)}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer3.AddPolicy(rule3) - if err := authorizer3.Authorize(); err != nil { - t.Errorf("Expected authorizer to pass fallback role '%s', got err: %v", api.RoleNode, err) - } - - // Case 4: GKE Workload Identity projected token (no groups claim, sub-based mapping) - claims4 := jwt.MapClaims{ - "sub": "system:serviceaccount:sam-canary:sam-node-sa", - } - biscuitData4, _, err := MintBiscuitToken(priv, claims4, token, dummyPeer, policy, token.Expiry, "canary-role") - if err != nil { - t.Fatal(err) - } - b4, err := biscuit.Unmarshal(biscuitData4) - if err != nil { - t.Fatal(err) - } - authorizer4, err := b4.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } - rule4 := biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String("canary-role")}}, - }, - }, - }, Kind: biscuit.PolicyKindAllow} - authorizer4.AddPolicy(rule4) - if err := authorizer4.Authorize(); err != nil { - t.Errorf("Expected sub-derived role 'canary-role' to be authorized: %v", err) - } -} - func TestVerifyBiscuit_Expiration(t *testing.T) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -231,7 +71,7 @@ func TestVerifyBiscuit_Expiration(t *testing.T) { "roles": []any{"admin"}, } - biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, &api.PolicyConfig{}, token.Expiry, api.RoleNode) + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, []string{"admin"}, nil) if err != nil { t.Fatalf("MintBiscuitToken failed: %v", err) } @@ -253,17 +93,6 @@ func TestMintBiscuitToken_ClaimsTranslation(t *testing.T) { t.Fatal(err) } - policy := &api.PolicyConfig{ - Bindings: []api.Binding{ - {Role: "developer-role", Members: []string{"group:engineering"}}, - }, - Roles: map[string]api.RolePolicy{ - "developer-role": { - AllowedServices: []string{"mcp:git-helper"}, - }, - }, - } - privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) if err != nil { t.Fatal(err) @@ -283,7 +112,7 @@ func TestMintBiscuitToken_ClaimsTranslation(t *testing.T) { "groups": []any{"beta-testers", "engineering"}, } - biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, policy, token.Expiry, "developer-role") + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, nil, nil) if err != nil { t.Fatalf("Failed to mint biscuit: %v", err) } @@ -350,136 +179,6 @@ func TestMintBiscuitToken_ClaimsTranslation(t *testing.T) { } } -func TestMintBiscuitToken_VariousClaimsTypes(t *testing.T) { - pub, priv, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - - policy := &api.PolicyConfig{ - Bindings: []api.Binding{ - {Role: "eng-role", Members: []string{"group:eng-group"}}, - }, - Roles: map[string]api.RolePolicy{ - "admin": {}, - "eng-role": {}, - }, - } - - privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) - if err != nil { - t.Fatal(err) - } - dummyPeer, err := peer.IDFromPrivateKey(privNode) - if err != nil { - t.Fatal(err) - } - - token := &oidc.IDToken{ - Expiry: time.Now().Add(1 * time.Hour), - } - - tests := []struct { - name string - rolesClaim any - groupsClaim any - expectedRoles []string - expectedGroups []string - }{ - { - name: "String slice (standard go code paths)", - rolesClaim: []string{"admin"}, - groupsClaim: []string{"eng-group", "beta"}, - expectedRoles: []string{"admin", "eng-role"}, - expectedGroups: []string{"eng-group", "beta"}, - }, - { - name: "Interface slice (standard JSON unmarshalled jwt paths)", - rolesClaim: []any{"admin"}, - groupsClaim: []any{"eng-group", "beta"}, - expectedRoles: []string{"admin", "eng-role"}, - expectedGroups: []string{"eng-group", "beta"}, - }, - { - name: "Single string claims", - rolesClaim: "admin", - groupsClaim: "eng-group", - expectedRoles: []string{"admin", "eng-role"}, - expectedGroups: []string{"eng-group"}, - }, - { - name: "Missing or nil claims", - rolesClaim: nil, - groupsClaim: nil, - expectedRoles: nil, - expectedGroups: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - claims := jwt.MapClaims{} - if tt.rolesClaim != nil { - claims["roles"] = tt.rolesClaim - } - if tt.groupsClaim != nil { - claims["groups"] = tt.groupsClaim - } - - rolesToTest := tt.expectedRoles - if len(rolesToTest) == 0 { - rolesToTest = []string{api.RoleNode} - } - - for _, r := range rolesToTest { - biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, policy, token.Expiry, r) - if err != nil { - t.Fatalf("MintBiscuitToken failed for role %s: %v", r, err) - } - - b, err := biscuit.Unmarshal(biscuitData) - if err != nil { - t.Fatalf("Unmarshal biscuit failed: %v", err) - } - - authorizer, err := b.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatalf("Authorizer failed: %v", err) - } - - authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ - { - Body: []biscuit.Predicate{ - {Name: "role", IDs: []biscuit.Term{biscuit.String(r)}}, - }, - }, - }}) - - for _, g := range tt.expectedGroups { - authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ - { - Body: []biscuit.Predicate{ - {Name: "group", IDs: []biscuit.Term{biscuit.String(g)}}, - }, - }, - }}) - } - - authorizer.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{}, - }, - }, Kind: biscuit.PolicyKindAllow}) - - if err := authorizer.Authorize(); err != nil { - t.Errorf("Verification failed for case %s, role %s: %v\nWorld:\n%s", tt.name, r, err, authorizer.PrintWorld()) - } - } - }) - } -} - func TestVerifyBiscuit_Concurrent(t *testing.T) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -503,7 +202,7 @@ func TestVerifyBiscuit_Concurrent(t *testing.T) { "roles": []any{"admin"}, } - biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, &api.PolicyConfig{}, token.Expiry, api.RoleNode) + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, []string{"admin"}, nil) if err != nil { t.Fatalf("MintBiscuitToken failed: %v", err) } @@ -527,23 +226,12 @@ func TestVerifyBiscuit_Concurrent(t *testing.T) { wg.Wait() } -func TestMintBiscuitToken_ErrorAggregation(t *testing.T) { - _, priv, err := ed25519.GenerateKey(rand.Reader) +func TestMintBiscuitToken(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } - policy := &api.PolicyConfig{ - Roles: map[string]api.RolePolicy{ - "admin": { - CustomDatalog: []string{ - "invalid-datalog-fact(123", // syntax error - "another-bad-fact);", // syntax error - }, - }, - }, - } - privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) if err != nil { t.Fatal(err) @@ -556,45 +244,49 @@ func TestMintBiscuitToken_ErrorAggregation(t *testing.T) { token := &oidc.IDToken{ Expiry: time.Now().Add(1 * time.Hour), } - claims := jwt.MapClaims{ - "sub": "user-123", - "roles": []any{"admin"}, - } - _, _, err = MintBiscuitToken(priv, claims, token, dummyPeer, policy, token.Expiry, "admin") - if err == nil { - t.Fatal("Expected MintBiscuitToken to fail on custom datalog syntax errors, got nil error") + claims := jwt.MapClaims{ + "sub": "test-user", + "groups": []any{"group1", "group2"}, + "roles": []any{"admin", "user"}, } - - errStr := err.Error() - if !strings.Contains(errStr, "failed to parse custom fact") && !strings.Contains(errStr, "panic parsing custom fact") { - t.Errorf("Expected error message to contain parse failure info, got: %s", errStr) + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, []string{"admin", "user"}, nil) + if err != nil { + t.Fatal(err) } - if !strings.Contains(errStr, "invalid-datalog-fact") || !strings.Contains(errStr, "another-bad-fact") { - t.Errorf("Expected error to aggregate both failures, got: %s", errStr) + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatal(err) } -} - -func TestMintBiscuitToken_FactDeduplication(t *testing.T) { - _, priv, err := ed25519.GenerateKey(rand.Reader) + authorizer, err := b.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) if err != nil { t.Fatal(err) } - policy := &api.PolicyConfig{ - Bindings: []api.Binding{ - {Role: "role-a", Members: []string{"user:user-1"}}, - {Role: "role-b", Members: []string{"user:user-1"}}, - }, - Roles: map[string]api.RolePolicy{ - "role-a": { - AllowedTargets: []string{"*:*"}, - }, - "role-b": { - AllowedTargets: []string{"*:*"}, + // Add policies to check that facts were added correctly. + authorizer.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{ + {Name: "user", IDs: []biscuit.Term{biscuit.String("test-user")}}, + {Name: "group", IDs: []biscuit.Term{biscuit.String("group1")}}, + {Name: "group", IDs: []biscuit.Term{biscuit.String("group2")}}, + {Name: "role", IDs: []biscuit.Term{biscuit.String("admin")}}, + {Name: "role", IDs: []biscuit.Term{biscuit.String("user")}}, }, }, + }, Kind: biscuit.PolicyKindAllow}) + + if err := authorizer.Authorize(); err != nil { + t.Errorf("Expected facts to be present, got error: %v\nWorld:\n%s", err, authorizer.PrintWorld()) + } +} + +func TestMintBiscuitToken_VariousClaimsTypes(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) } privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) @@ -609,21 +301,104 @@ func TestMintBiscuitToken_FactDeduplication(t *testing.T) { token := &oidc.IDToken{ Expiry: time.Now().Add(1 * time.Hour), } - claims := jwt.MapClaims{ - "sub": "user-1", - } - biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, policy, token.Expiry, "role-a") - if err != nil { - t.Fatalf("MintBiscuitToken failed with duplicate facts: %v", err) + tests := []struct { + name string + rolesClaim any + groupsClaim any + expectedRoles []string + expectedGroups []string + }{ + { + name: "String slice (standard go code paths)", + rolesClaim: []string{"admin", "eng-role"}, + groupsClaim: []string{"eng-group", "beta"}, + expectedRoles: []string{"admin", "eng-role"}, + expectedGroups: []string{"eng-group", "beta"}, + }, + { + name: "Interface slice (standard JSON unmarshalled jwt paths)", + rolesClaim: []any{"admin", "eng-role"}, + groupsClaim: []any{"eng-group", "beta"}, + expectedRoles: []string{"admin", "eng-role"}, + expectedGroups: []string{"eng-group", "beta"}, + }, + { + name: "Single string claims", + rolesClaim: "admin", + groupsClaim: "eng-group", + expectedRoles: []string{"admin"}, + expectedGroups: []string{"eng-group"}, + }, + { + name: "Missing or nil claims", + rolesClaim: nil, + groupsClaim: nil, + expectedRoles: nil, + expectedGroups: nil, + }, } - if len(biscuitData) == 0 { - t.Error("Expected valid biscuit data, got empty") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + claims := jwt.MapClaims{} + if tt.rolesClaim != nil { + claims["roles"] = tt.rolesClaim + } + if tt.groupsClaim != nil { + claims["groups"] = tt.groupsClaim + } + + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, tt.expectedRoles, nil) + if err != nil { + t.Fatalf("MintBiscuitToken failed: %v", err) + } + + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatalf("Unmarshal biscuit failed: %v", err) + } + + authorizer, err := b.Authorizer(pub) + if err != nil { + t.Fatalf("Authorizer failed: %v", err) + } + + for _, r := range tt.expectedRoles { + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "role", IDs: []biscuit.Term{biscuit.String(r)}}, + }, + }, + }}) + } + + for _, g := range tt.expectedGroups { + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + { + Body: []biscuit.Predicate{ + {Name: "group", IDs: []biscuit.Term{biscuit.String(g)}}, + }, + }, + }}) + } + + authorizer.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ + { + Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, + Body: []biscuit.Predicate{}, + }, + }, Kind: biscuit.PolicyKindAllow}) + + if err := authorizer.Authorize(); err != nil { + t.Errorf("Verification failed for case %s: %v\nWorld:\n%s", tt.name, err, authorizer.PrintWorld()) + } + }) } } -func TestMintBootstrapBiscuitToken(t *testing.T) { +func TestMintBiscuitToken_WithPolicyRoles(t *testing.T) { pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -638,167 +413,110 @@ func TestMintBootstrapBiscuitToken(t *testing.T) { t.Fatal(err) } - policy := &api.PolicyConfig{ - Roles: map[string]api.RolePolicy{ - "custom-node-role": { - AllowedServices: []string{"mcp:service1"}, - }, - }, + token := &oidc.IDToken{ + Expiry: time.Now().Add(1 * time.Hour), } + claims := jwt.MapClaims{"sub": "alice"} - // Case 1: Router Role - biscuitData1, err := MintBootstrapBiscuitToken(priv, dummyPeer, api.RoleRouter, time.Now().Add(1*time.Hour), policy) - if err != nil { - t.Fatal(err) + policyRoles := []*api.PolicyRole{ + { + Name: "data-scientist", + AllowedServices: []string{"mcp://calculator"}, + AllowedTargets: []string{"group:backend"}, + CustomDatalog: []string{"right(\"data:read\");"}, + }, } - b1, err := biscuit.Unmarshal(biscuitData1) - if err != nil { - t.Fatal(err) - } - authorizer1, err := b1.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } + t.Run("Mint with matching policy role", func(t *testing.T) { + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, []string{"data-scientist"}, policyRoles) + if err != nil { + t.Fatalf("MintBiscuitToken failed: %v", err) + } - // Verify right("relay") fact is present - checkRelay := biscuit.Check{Queries: []biscuit.Rule{ - { - Body: []biscuit.Predicate{ - {Name: api.FactRight, IDs: []biscuit.Term{biscuit.String(api.RightRelay)}}, - }, - }, - }} - authorizer1.AddCheck(checkRelay) + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatalf("Unmarshal biscuit failed: %v", err) + } - // Verify target_unrestricted() fact is present - checkUnrestricted := biscuit.Check{Queries: []biscuit.Rule{ - { - Body: []biscuit.Predicate{ - {Name: "target_unrestricted", IDs: []biscuit.Term{}}, - }, - }, - }} - authorizer1.AddCheck(checkUnrestricted) + authorizer, err := b.Authorizer(pub) + if err != nil { + t.Fatalf("Authorizer failed: %v", err) + } - authorizer1.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{}, - }, - }, Kind: biscuit.PolicyKindAllow}) + // Verify granted_service_exact, granted_target_exact, and custom datalog right fact + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + {Body: []biscuit.Predicate{{Name: api.FactGrantedServiceExact, IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("calculator")}}}}, + {Body: []biscuit.Predicate{{Name: api.FactGrantedTargetExact, IDs: []biscuit.Term{biscuit.String("group"), biscuit.String("backend")}}}}, + {Body: []biscuit.Predicate{{Name: api.FactRight, IDs: []biscuit.Term{biscuit.String("data:read")}}}}, + }}) + authorizer.AddPolicy(api.AllowIfTruePolicy) - if err := authorizer1.Authorize(); err != nil { - t.Errorf("Expected router facts checks to succeed: %v", err) - } + if err := authorizer.Authorize(); err != nil { + t.Errorf("Expected policy facts to be present in Biscuit, got error: %v\nWorld:\n%s", err, authorizer.PrintWorld()) + } + }) - // Case 2: Custom Node Role - biscuitData2, err := MintBootstrapBiscuitToken(priv, dummyPeer, "custom-node-role", time.Now().Add(1*time.Hour), policy) - if err != nil { - t.Fatal(err) - } + t.Run("Mint with unmapped role when policy exists", func(t *testing.T) { + biscuitData, _, err := MintBiscuitToken(priv, claims, token, dummyPeer, token.Expiry, []string{"unmapped-role"}, policyRoles) + if err != nil { + t.Fatalf("MintBiscuitToken failed: %v", err) + } - b2, err := biscuit.Unmarshal(biscuitData2) - if err != nil { - t.Fatal(err) - } - authorizer2, err := b2.Authorizer(pub, biscuit.WithWorldOptions(datalog.WithMaxDuration(500*time.Millisecond))) - if err != nil { - t.Fatal(err) - } + b, err := biscuit.Unmarshal(biscuitData) + if err != nil { + t.Fatalf("Unmarshal biscuit failed: %v", err) + } - // Verify exact match service fact derived from policy - checkServiceExact := biscuit.Check{Queries: []biscuit.Rule{ - { - Body: []biscuit.Predicate{ - {Name: "granted_service_exact", IDs: []biscuit.Term{biscuit.String("mcp"), biscuit.String("service1")}}, - }, - }, - }} - authorizer2.AddCheck(checkServiceExact) + authorizer, err := b.Authorizer(pub) + if err != nil { + t.Fatalf("Authorizer failed: %v", err) + } - authorizer2.AddPolicy(biscuit.Policy{Queries: []biscuit.Rule{ - { - Head: biscuit.Predicate{Name: "allow", IDs: []biscuit.Term{}}, - Body: []biscuit.Predicate{}, - }, - }, Kind: biscuit.PolicyKindAllow}) + // Verify target_unrestricted and granted_service_all_types are NOT present + authorizer.AddCheck(biscuit.Check{Queries: []biscuit.Rule{ + {Body: []biscuit.Predicate{{Name: api.FactTargetUnrestricted, IDs: []biscuit.Term{}}}}, + }}) + authorizer.AddPolicy(api.AllowIfTruePolicy) - if err := authorizer2.Authorize(); err != nil { - t.Errorf("Expected custom node role service checks to succeed: %v", err) - } + if err := authorizer.Authorize(); err == nil { + t.Errorf("Expected target_unrestricted check to fail for unmapped role, but it succeeded\nWorld:\n%s", authorizer.PrintWorld()) + } + }) } -func TestMintBiscuitToken_RoleAuthorizationValidation(t *testing.T) { - _, priv, err := ed25519.GenerateKey(rand.Reader) +func TestVerifyAndExtractPeerID_MultipleTrustedKeys(t *testing.T) { + pub1, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } - - privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + pub2, priv2, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) } - dummyPeer, _ := peer.IDFromPrivateKey(privNode) - - token := &oidc.IDToken{ - Expiry: time.Now().Add(1 * time.Hour), - } - - // Policy defining bindings for custom capability roles - policy := &api.PolicyConfig{ - Bindings: []api.Binding{ - {Role: "sam:role:sambox", Members: []string{"user:sambox-user"}}, - {Role: "custom-access-role", Members: []string{"user:sambox-user"}}, - }, - Roles: map[string]api.RolePolicy{ - "sam:role:sambox": {}, - "custom-access-role": {}, - }, - } - // 1. User has no mapped capability roles. - // Can request default "sam:role:node", but not "sam:role:router". - claimsUnmapped := jwt.MapClaims{"sub": "unmapped-user"} - _, _, err = MintBiscuitToken(priv, claimsUnmapped, token, dummyPeer, policy, token.Expiry, api.RoleNode) + privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) if err != nil { - t.Errorf("expected unmapped user allowed to request default role %q, got: %v", api.RoleNode, err) + t.Fatal(err) } - _, _, err = MintBiscuitToken(priv, claimsUnmapped, token, dummyPeer, policy, token.Expiry, api.RoleRouter) - if err == nil { - t.Errorf("expected unmapped user NOT allowed to request unauthorized role %q", api.RoleRouter) + dummyPeer, err := peer.IDFromPrivateKey(privNode) + if err != nil { + t.Fatal(err) } - // 2. User has mapped capability role "sam:role:sambox" and custom access role "custom-access-role". - // Can request "sam:role:sambox", but cannot request default "sam:role:node" or "sam:role:router". - claimsMapped := jwt.MapClaims{"sub": "sambox-user"} - _, finalRoles, err := MintBiscuitToken(priv, claimsMapped, token, dummyPeer, policy, token.Expiry, api.RoleSamBox) + biscuitData, err := MintBootstrapBiscuitToken(priv2, dummyPeer, api.RoleNode, time.Now().Add(1*time.Hour), nil) if err != nil { - t.Errorf("expected mapped user allowed to request authorized role %q, got: %v", api.RoleSamBox, err) - } - // Check that custom access role is also preserved in final roles - hasCustomRole := false - for _, r := range finalRoles { - if r == "custom-access-role" { - hasCustomRole = true - } - } - if !hasCustomRole { - t.Errorf("expected custom access role to be preserved in final roles, got: %v", finalRoles) + t.Fatalf("MintBootstrapBiscuitToken failed: %v", err) } - _, _, err = MintBiscuitToken(priv, claimsMapped, token, dummyPeer, policy, token.Expiry, api.RoleNode) - if err == nil { - t.Errorf("expected mapped user NOT allowed to bypass map by requesting default role %q", api.RoleNode) - } - _, _, err = MintBiscuitToken(priv, claimsMapped, token, dummyPeer, policy, token.Expiry, api.RoleRouter) - if err == nil { - t.Errorf("expected mapped user NOT allowed to request unauthorized role %q", api.RoleRouter) + // trustedPublicKeys has pub1 first, pub2 second (pub2 is the signer) + trustedKeys := []ed25519.PublicKey{pub1, pub2} + + extractedPeer, err := VerifyAndExtractPeerID(trustedKeys, biscuitData, 5*time.Second) + if err != nil { + t.Fatalf("VerifyAndExtractPeerID failed with multiple trusted keys: %v", err) } - // 3. Requested role cannot be empty - _, _, err = MintBiscuitToken(priv, claimsMapped, token, dummyPeer, policy, token.Expiry, "") - if err == nil { - t.Error("expected MintBiscuitToken to reject empty requested_role") + if extractedPeer != dummyPeer { + t.Errorf("expected peer ID %s, got %s", dummyPeer, extractedPeer) } } diff --git a/internal/node/enroll.go b/internal/node/enroll.go index bdcfa313..c5b31ade 100644 --- a/internal/node/enroll.go +++ b/internal/node/enroll.go @@ -86,9 +86,7 @@ func (n *SamNode) Enroll(ctx context.Context, hubURL string, jwt string) error { } httpReq.Header.Set("Content-Type", "application/x-protobuf") - client := &http.Client{ - Timeout: 30 * time.Second, - } + client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(httpReq) if err != nil { return fmt.Errorf("HTTP request failed: %v", err) @@ -99,64 +97,72 @@ func (n *SamNode) Enroll(ctx context.Context, hubURL string, jwt string) error { } }() + enrollResp, err := n.processEnrollResponse(resp) + if err != nil { + return err + } + + return n.connectToHubAddresses(ctx, enrollResp.HubAddresses) +} + +func (n *SamNode) processEnrollResponse(resp *http.Response) (*api.EnrollResponse, error) { if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("enrollment failed with status %s: %s", resp.Status, string(body)) + return nil, fmt.Errorf("enrollment failed with status %s: %s", resp.Status, string(body)) } respData, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("failed to read response body: %v", err) + return nil, fmt.Errorf("failed to read response body: %v", err) } var enrollResp api.EnrollResponse if err := proto.Unmarshal(respData, &enrollResp); err != nil { - return fmt.Errorf("failed to unmarshal response: %v", err) + return nil, fmt.Errorf("failed to unmarshal response: %v", err) } if enrollResp.ErrorMessage != "" { - return fmt.Errorf("enrollment failed: %s", enrollResp.ErrorMessage) + return nil, fmt.Errorf("enrollment failed: %s", enrollResp.ErrorMessage) } - if len(enrollResp.BiscuitToken) == 0 { - return fmt.Errorf("received empty biscuit token") + return nil, fmt.Errorf("received empty biscuit token") } - if len(enrollResp.HubPublicKey) != ed25519.PublicKeySize { - return fmt.Errorf("received invalid hub public key size: %d bytes (expected %d)", len(enrollResp.HubPublicKey), ed25519.PublicKeySize) + return nil, fmt.Errorf("received invalid hub public key size: %d bytes (expected %d)", len(enrollResp.HubPublicKey), ed25519.PublicKeySize) } if n.config.RequiredRole != "" { if err := identity.VerifyBiscuitRole(enrollResp.BiscuitToken, ed25519.PublicKey(enrollResp.HubPublicKey), n.config.RequiredRole); err != nil { - return fmt.Errorf("enrolled biscuit token lacks required role %q: %w", n.config.RequiredRole, err) + return nil, fmt.Errorf("enrolled biscuit token lacks required role %q: %w", n.config.RequiredRole, err) } } if err := n.Store.SaveIdentity(enrollResp.BiscuitToken); err != nil { - return fmt.Errorf("failed to save identity: %v", err) + return nil, fmt.Errorf("failed to save identity: %v", err) } n.SetIdentityCache(enrollResp.BiscuitToken) if err := n.Store.SaveIdentityExpiration(enrollResp.Expiration); err != nil { - return fmt.Errorf("failed to save identity expiration: %v", err) + return nil, fmt.Errorf("failed to save identity expiration: %v", err) } - if err := n.Store.SaveHubConfig(enrollResp.HubPublicKey, enrollResp.HubAddresses); err != nil { - return fmt.Errorf("failed to save hub config: %v", err) + return nil, fmt.Errorf("failed to save hub config: %v", err) } n.keysMu.Lock() n.trustedKeys = append(n.trustedKeys, TrustedKey{Key: ed25519.PublicKey(enrollResp.HubPublicKey), ReceivedAt: time.Now()}) n.keysMu.Unlock() - // Connect and Auth to hub after enrollment to join the mesh - if len(enrollResp.HubAddresses) == 0 { + return &enrollResp, nil +} + +func (n *SamNode) connectToHubAddresses(ctx context.Context, addrs []string) error { + if len(addrs) == 0 { return fmt.Errorf("failed to connect and authenticate after HTTP enrollment: control plane returned no hub addresses") } var lastAuthErr error - var authed bool - for _, addrStr := range enrollResp.HubAddresses { + for _, addrStr := range addrs { addr, err := multiaddr.NewMultiaddr(addrStr) if err != nil { logger.Warnf("Failed to parse hub address from response: %v", err) @@ -166,17 +172,12 @@ func (n *SamNode) Enroll(ctx context.Context, hubURL string, jwt string) error { logger.Warnf("Failed to connect and auth with hub after enrollment: %v", err) lastAuthErr = err } else { - authed = true - break + logger.Info("Successfully enrolled via HTTP and stored identity and hub config.") + return nil } } - if !authed { - return fmt.Errorf("failed to connect and authenticate with any hub after HTTP enrollment (last error: %v)", lastAuthErr) - } - - logger.Info("Successfully enrolled via HTTP and stored identity and hub config.") - return nil + return fmt.Errorf("failed to connect and authenticate with any hub after HTTP enrollment (last error: %v)", lastAuthErr) } // EnrollBootstrap enrolls the node with the control plane using a pre-shared bootstrap token. diff --git a/internal/node/enroll_test.go b/internal/node/enroll_test.go index a247b2dc..f04bcbc6 100644 --- a/internal/node/enroll_test.go +++ b/internal/node/enroll_test.go @@ -17,6 +17,7 @@ package node import ( "bytes" "context" + "io" "net/http" "net/http/httptest" "strings" @@ -108,3 +109,87 @@ func TestEnroll_InvalidHubPublicKeySize(t *testing.T) { t.Fatalf("Expected invalid public key size error, got: %v", err) } } + +func TestProcessEnrollResponse_Errors(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + priv, _, _ := crypto.GenerateKeyPair(crypto.Ed25519, -1) + node, err := NewSamNode(Options{ + PrivKey: priv, + Store: store, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + }) + if err != nil { + t.Fatal(err) + } + + t.Run("Non200Status", func(t *testing.T) { + resp := &http.Response{ + StatusCode: http.StatusBadRequest, + Status: "400 Bad Request", + Body: io.NopCloser(strings.NewReader("bad request details")), + } + _, err := node.processEnrollResponse(resp) + if err == nil || !strings.Contains(err.Error(), "enrollment failed with status 400") { + t.Fatalf("Expected status 400 error, got: %v", err) + } + }) + + t.Run("ErrorMessageInResponse", func(t *testing.T) { + enrollResp := &api.EnrollResponse{ErrorMessage: "unauthorized user"} + data, _ := proto.Marshal(enrollResp) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + _, err := node.processEnrollResponse(resp) + if err == nil || !strings.Contains(err.Error(), "enrollment failed: unauthorized user") { + t.Fatalf("Expected enrollment failed error message, got: %v", err) + } + }) + + t.Run("EmptyBiscuitToken", func(t *testing.T) { + enrollResp := &api.EnrollResponse{ + BiscuitToken: nil, + HubPublicKey: make([]byte, 32), + } + data, _ := proto.Marshal(enrollResp) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + _, err := node.processEnrollResponse(resp) + if err == nil || !strings.Contains(err.Error(), "received empty biscuit token") { + t.Fatalf("Expected empty biscuit token error, got: %v", err) + } + }) +} + +func TestConnectToHubAddresses_EmptyAddrs(t *testing.T) { + dir := t.TempDir() + store, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + + priv, _, _ := crypto.GenerateKeyPair(crypto.Ed25519, -1) + node, err := NewSamNode(Options{ + PrivKey: priv, + Store: store, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + }) + if err != nil { + t.Fatal(err) + } + + err = node.connectToHubAddresses(context.Background(), nil) + if err == nil || !strings.Contains(err.Error(), "returned no hub addresses") { + t.Fatalf("Expected no hub addresses error, got: %v", err) + } +} diff --git a/internal/node/hub_config.go b/internal/node/hub_config.go index 9db3441c..145cbb1f 100644 --- a/internal/node/hub_config.go +++ b/internal/node/hub_config.go @@ -16,6 +16,7 @@ package node import ( "context" + "encoding/base64" "fmt" "io" "net/http" @@ -138,3 +139,44 @@ func SyncHubConfig(ctx context.Context, s *Store) ([]byte, []multiaddr.Multiaddr return pubKey, hubAddrs, nil } + +// FetchMeshPolicy retrieves the latest mesh policy from the Hub's /policies endpoint using a biscuit token. +func FetchMeshPolicy(ctx context.Context, hubURL string, biscuitToken []byte) (*api.PolicyConfigGetResponse, error) { + if !strings.HasPrefix(hubURL, "http://") && !strings.HasPrefix(hubURL, "https://") { + hubURL = "https://" + hubURL + } + hubURL = strings.TrimSuffix(hubURL, "/") + + urlStr := hubURL + "/policies" + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString(biscuitToken)) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() //nolint:errcheck + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("hub returned status %s: %s", resp.Status, string(body)) + } + + var policyResp api.PolicyConfigGetResponse + if err := proto.Unmarshal(body, &policyResp); err != nil { + return nil, fmt.Errorf("failed to decode /policies response: %w", err) + } + + return &policyResp, nil +} diff --git a/internal/node/middleware.go b/internal/node/middleware.go index 3533b8ca..d4821a6e 100644 --- a/internal/node/middleware.go +++ b/internal/node/middleware.go @@ -249,6 +249,15 @@ func (n *SamNode) Authorize(rawToken []byte, req RequestContext, pubKey ed25519. authorizer.AddRule(r) } + // Apply Dynamic Mesh Policy Rules + n.MeshPolicyMu.RLock() + meshRules := n.MeshPolicyRules + n.MeshPolicyMu.RUnlock() + + for _, r := range meshRules { + authorizer.AddRule(r) + } + err = authorizer.Authorize() if err != nil { logger.Errorf("Authorizer failure: %v, token: %s", err, b.String()) diff --git a/internal/node/node.go b/internal/node/node.go index f8184b0b..ca62c90b 100644 --- a/internal/node/node.go +++ b/internal/node/node.go @@ -137,6 +137,8 @@ type SamNode struct { authPeers sync.Map trustedKeys []TrustedKey keysMu sync.RWMutex + MeshPolicyRules []biscuit.Rule + MeshPolicyMu sync.RWMutex rateLimiter *PeerRateLimiter services *ServiceRegistry BoundHTTPAddr string @@ -533,6 +535,9 @@ func (n *SamNode) Start(ctx context.Context) error { // Periodically and on-demand reprovide registered services to the DHT n.startReprovideLoop(ctx, ReprovideInterval) + // Periodically sync mesh policy + n.startPolicySyncLoop(ctx, n.config.PolicySyncInterval) + return nil } @@ -1125,6 +1130,20 @@ func (n *SamNode) listenForHubEvents(ctx context.Context) { n.handleBannedEvent(&event) case api.MeshEvent_KEY_ROTATION: n.handleKeyRotationEvent(&event) + case api.MeshEvent_POLICY_UPDATE: + logger.Infof("[Mesh Event] Received POLICY_UPDATE event from %s, triggering sync", msg.ReceivedFrom) + go func() { + // Jitter delay (0-2s) to prevent thundering herd on Control Plane + jitter := time.Duration(rand.Intn(2000)) * time.Millisecond + select { + case <-time.After(jitter): + case <-ctx.Done(): + return + } + if err := n.syncMeshPolicy(ctx); err != nil { + logger.Warnf("Failed to sync mesh policy after event: %v", err) + } + }() } } } @@ -1202,7 +1221,7 @@ func (n *SamNode) startKeyPruning(ctx context.Context, gracePeriod time.Duration func (n *SamNode) verifyEvent(event *api.MeshEvent) bool { sig := event.Signature event.Signature = nil - data, err := proto.Marshal(event) + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) event.Signature = sig // Restore if err != nil { logger.Errorf("[Mesh Event] Failed to marshal event for verification: %v", err) @@ -1932,3 +1951,61 @@ func hasCircuit(addr multiaddr.Multiaddr) bool { } return false } + +func (n *SamNode) syncMeshPolicy(ctx context.Context) error { + hubURL, err := n.Store.LoadHubURL() + if err != nil || hubURL == "" { + return fmt.Errorf("hub URL not found in store") + } + + token := n.GetIdentity() + if len(token) == 0 { + return fmt.Errorf("node has no identity token to fetch mesh policy") + } + + policyResp, err := FetchMeshPolicy(ctx, hubURL, token) + if err != nil { + return fmt.Errorf("failed to fetch mesh policy: %w", err) + } + + rules := BuildPolicyRules(policyResp.Roles, policyResp.Bindings) + + n.MeshPolicyMu.Lock() + n.MeshPolicyRules = rules + n.MeshPolicyMu.Unlock() + + logger.Infof("Successfully synchronized mesh policy (generated %d rules)", len(rules)) + return nil +} + +func (n *SamNode) startPolicySyncLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 1 * time.Hour + } + + go func() { + // Run initial sync after a short delay + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): + if err := n.syncMeshPolicy(ctx); err != nil { + logger.Warnf("Initial mesh policy sync failed: %v", err) + } + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := n.syncMeshPolicy(ctx); err != nil { + logger.Warnf("Periodic mesh policy sync failed: %v", err) + } + } + } + }() +} diff --git a/internal/node/oidc.go b/internal/node/oidc.go index f9528bfd..130b6acb 100644 --- a/internal/node/oidc.go +++ b/internal/node/oidc.go @@ -228,22 +228,24 @@ func (n *SamNode) InteractiveLogin(ctx context.Context, authURL, tokenURL, clien } }() + shutdownSrv := func() { + if srv != nil { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = srv.Shutdown(shutdownCtx) + cancel() + } + } + var code string select { case <-ctx.Done(): - if srv != nil { - _ = srv.Close() - } + shutdownSrv() return "", ctx.Err() case err := <-errChan: - if srv != nil { - _ = srv.Close() - } + shutdownSrv() return "", err case code = <-codeChan: - if srv != nil { - _ = srv.Close() - } + shutdownSrv() } tokenData := url.Values{} diff --git a/internal/node/options.go b/internal/node/options.go index 40519148..fff8ef8a 100644 --- a/internal/node/options.go +++ b/internal/node/options.go @@ -58,6 +58,8 @@ type Options struct { DiscoveryConcurrency int // RequiredRole restricts enrollment and startup to only accept tokens containing this role. RequiredRole string + // PolicySyncInterval specifies how often the node syncs the mesh policy from the Hub. + PolicySyncInterval time.Duration } // Default applies default values to Options if they are not specified. @@ -98,6 +100,9 @@ func (o *Options) Default() { if o.RequiredRole == "" { o.RequiredRole = api.RoleNode } + if o.PolicySyncInterval == 0 { + o.PolicySyncInterval = 1 * time.Hour + } } // Validate verifies that the required options are provided and valid. diff --git a/internal/node/policy.go b/internal/node/policy.go new file mode 100644 index 00000000..f80d4c3d --- /dev/null +++ b/internal/node/policy.go @@ -0,0 +1,132 @@ +package node + +import ( + "strings" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/biscuit-auth/biscuit-go/v2/parser" + "github.com/google/sam/api" +) + +func BuildPolicyRules(roles []*api.PolicyRole, bindings []*api.PolicyBinding) []biscuit.Rule { + var rules []biscuit.Rule + + for _, b := range bindings { + if b == nil { + continue + } + for _, m := range b.Members { + if m == api.SystemAuthenticated { + rules = append(rules, biscuit.Rule{ + Head: biscuit.Predicate{ + Name: api.FactRole, + IDs: []biscuit.Term{biscuit.String(b.Role)}, + }, + Body: []biscuit.Predicate{}, + }) + continue + } + parts := strings.SplitN(m, ":", 2) + if len(parts) == 2 { + memberType := parts[0] + memberVal := parts[1] + rules = append(rules, biscuit.Rule{ + Head: biscuit.Predicate{ + Name: api.FactRole, + IDs: []biscuit.Term{biscuit.String(b.Role)}, + }, + Body: []biscuit.Predicate{ + {Name: memberType, IDs: []biscuit.Term{biscuit.String(memberVal)}}, + }, + }) + } + } + } + + for _, role := range roles { + if role == nil { + continue + } + roleName := role.Name + + for _, svc := range role.AllowedServices { + fact := api.BuildServiceDatalogFact(svc) + rules = append(rules, biscuit.Rule{ + Head: fact.Predicate, + Body: []biscuit.Predicate{ + {Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(roleName)}}, + }, + }) + } + + hasUnrestricted := false + hasSpecificTargets := false + for _, t := range role.AllowedTargets { + if t == "*" { + hasUnrestricted = true + } else { + hasSpecificTargets = true + } + } + + if hasUnrestricted { + rules = append(rules, biscuit.Rule{ + Head: biscuit.Predicate{ + Name: api.FactTargetUnrestricted, + IDs: []biscuit.Term{}, + }, + Body: []biscuit.Predicate{ + {Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(roleName)}}, + }, + }) + } + + if hasSpecificTargets { + rules = append(rules, biscuit.Rule{ + Head: biscuit.Predicate{ + Name: api.FactTargetRestricted, + IDs: []biscuit.Term{}, + }, + Body: []biscuit.Predicate{ + {Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(roleName)}}, + }, + }) + } + + for _, t := range role.AllowedTargets { + if t == "*" { + continue + } + fact := api.BuildTargetDatalogFact(t) + rules = append(rules, biscuit.Rule{ + Head: fact.Predicate, + Body: []biscuit.Predicate{ + {Name: api.FactRole, IDs: []biscuit.Term{biscuit.String(roleName)}}, + }, + }) + } + + for _, dl := range role.CustomDatalog { + trimmed := strings.TrimRight(strings.TrimSpace(dl), ";") + if trimmed == "" { + continue + } + r, err := parser.FromStringRule(trimmed) + if err == nil { + rules = append(rules, r) + } else { + f, err2 := parser.FromStringFact(trimmed) + if err2 == nil { + rules = append(rules, biscuit.Rule{ + Head: f.Predicate, + Body: []biscuit.Predicate{}, + }) + } else { + logger.Warnf("Failed to parse custom Datalog rule/fact %q for role %s: rule_err=%v, fact_err=%v", dl, roleName, err, err2) + } + } + } + } + + return rules +} diff --git a/internal/node/policy_test.go b/internal/node/policy_test.go new file mode 100644 index 00000000..67d35a1f --- /dev/null +++ b/internal/node/policy_test.go @@ -0,0 +1,81 @@ +package node + +import ( + "strings" + "testing" + + "github.com/biscuit-auth/biscuit-go/v2" + "github.com/google/sam/api" +) + +func ruleToString(r biscuit.Rule) string { + head := r.Head.String() + if len(r.Body) == 0 { + return head + } + var bodyParts []string + for _, b := range r.Body { + bodyParts = append(bodyParts, b.String()) + } + return head + " <- " + strings.Join(bodyParts, ", ") +} + +func TestBuildPolicyRules(t *testing.T) { + roles := []*api.PolicyRole{ + { + Name: "test-role", + AllowedTargets: []string{"*", "node:peer-abc", "custom-fact:custom-val", "legacy-peer"}, + AllowedServices: []string{"*:*", "mcp:*", "mcp:*.suffix", "mcp:prefix.*", "mcp:exact"}, + CustomDatalog: []string{ + "custom_rule($x) <- fact($x);", + "custom_fact(\"hello\")", + }, + }, + } + bindings := []*api.PolicyBinding{ + { + Role: "test-role", + Members: []string{"sam:system:authenticated", "user:alice"}, + }, + } + + rules := BuildPolicyRules(roles, bindings) + + expectedStrings := map[string]bool{ + "role(\"test-role\")": false, + "role(\"test-role\") <- user(\"alice\")": false, + "granted_service_all_types() <- role(\"test-role\")": false, + "granted_service_all(\"mcp\") <- role(\"test-role\")": false, + "granted_service_suffix(\"mcp\", \".suffix\") <- role(\"test-role\")": false, + "granted_service_prefix(\"mcp\", \"prefix.\") <- role(\"test-role\")": false, + "granted_service_exact(\"mcp\", \"exact\") <- role(\"test-role\")": false, + "target_unrestricted() <- role(\"test-role\")": false, + "target_restricted() <- role(\"test-role\")": false, + "granted_target_exact(\"node\", \"peer-abc\") <- role(\"test-role\")": false, + "granted_target_exact(\"custom-fact\", \"custom-val\") <- role(\"test-role\")": false, + "granted_target_exact(\"node\", \"legacy-peer\") <- role(\"test-role\")": false, + "custom_rule($x) <- fact($x)": false, + "custom_fact(\"hello\")": false, + } + + for _, rule := range rules { + rStr := ruleToString(rule) + found := false + for k := range expectedStrings { + if rStr == k { + expectedStrings[k] = true + found = true + break + } + } + if !found { + t.Errorf("Unexpected rule generated: %q", rStr) + } + } + + for k, v := range expectedStrings { + if !v { + t.Errorf("Expected rule was not generated: %q", k) + } + } +} diff --git a/internal/node/static_service_test.go b/internal/node/static_service_test.go index a9ae79c2..f74fc55d 100644 --- a/internal/node/static_service_test.go +++ b/internal/node/static_service_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/ed25519" "crypto/rand" + "errors" "io" "net/http" "net/http/httptest" @@ -208,12 +209,14 @@ func TestStaticServiceRegistrationRequiresConnection(t *testing.T) { }, } - err = node.RegisterStaticServices(ctx, services) + ctxTimeout, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() + err = node.RegisterStaticServices(ctxTimeout, services) if err == nil { t.Fatal("Expected RegisterStaticServices to fail before connecting to hub, but it succeeded") } - if !strings.Contains(err.Error(), "timeout waiting for DHT to be ready") { - t.Fatalf("Expected error to contain 'timeout waiting for DHT to be ready', got: %v", err) + if !strings.Contains(err.Error(), "timeout waiting for DHT to be ready") && !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("Expected error to indicate timeout or deadline exceeded, got: %v", err) } // 6. Now enroll and connect to hub (which sets up DHT and hub connection) diff --git a/internal/router/router.go b/internal/router/router.go index 42a2157a..c4fd72c6 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -65,6 +65,10 @@ type relayACL struct { } func (a *relayACL) AllowReserve(p peer.ID, addr multiaddr.Multiaddr) bool { + if _, banned := a.r.bannedPeers.Load(p); banned { + logger.Debugf("[Relay] Rejecting reservation for %s: peer is banned", p) + return false + } _, ok := a.r.authenticatedPeers.Load(p) if !ok { logger.Debugf("[Relay] Rejecting reservation for %s: not authenticated", p) @@ -73,6 +77,14 @@ func (a *relayACL) AllowReserve(p peer.ID, addr multiaddr.Multiaddr) bool { } func (a *relayACL) AllowConnect(src peer.ID, srcAddr multiaddr.Multiaddr, dest peer.ID) bool { + if _, banned := a.r.bannedPeers.Load(src); banned { + logger.Debugf("[Relay] Rejecting connect from %s to %s: src is banned", src, dest) + return false + } + if _, banned := a.r.bannedPeers.Load(dest); banned { + logger.Debugf("[Relay] Rejecting connect from %s to %s: dest is banned", src, dest) + return false + } _, ok := a.r.authenticatedPeers.Load(dest) if !ok { logger.Debugf("[Relay] Rejecting connect from %s to %s: dest not authenticated", src, dest) @@ -88,6 +100,7 @@ type Router struct { PubSub *pubsub.PubSub EventTopic *pubsub.Topic authenticatedPeers sync.Map + bannedPeers sync.Map // Keys & Identity biscuitToken []byte @@ -270,11 +283,12 @@ func (r *Router) Start() error { }) // 5. Start background routines - r.wg.Add(4) + r.wg.Add(5) go r.runLeaseRenewalLoop() go r.runKeysSyncLoop() go r.runFederationLoop() go r.runBiscuitRenewalLoop() + go r.listenForHubEvents(r.ctx) r.isReady.Store(true) logger.Infof("Router Online. PeerID: %s, ListenAddrs: %v", r.Host.ID(), r.Host.Addrs()) @@ -515,6 +529,81 @@ func (r *Router) getTrustedPublicKeys() []ed25519.PublicKey { return append([]ed25519.PublicKey(nil), r.trustedPublicKeys...) } +func (r *Router) verifyEvent(event *api.MeshEvent) bool { + sig := event.Signature + event.Signature = nil + data, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + event.Signature = sig + if err != nil { + logger.Errorf("[Router Event] Failed to marshal event for verification: %v", err) + return false + } + + keys := r.getTrustedPublicKeys() + for _, pubKey := range keys { + if len(pubKey) == ed25519.PublicKeySize && ed25519.Verify(pubKey, data, sig) { + return true + } + } + return false +} + +func (r *Router) listenForHubEvents(ctx context.Context) { + defer r.wg.Done() + if r.EventTopic == nil { + return + } + sub, err := r.EventTopic.Subscribe() + if err != nil { + logger.Errorf("[Router Event] Failed to subscribe to GossipEvents topic: %v", err) + return + } + defer sub.Cancel() + + for { + msg, err := sub.Next(ctx) + if err != nil { + return + } + + var event api.MeshEvent + if err := proto.Unmarshal(msg.Data, &event); err != nil { + logger.Errorf("[Router Event] Failed to unmarshal event from %s: %v", msg.ReceivedFrom, err) + continue + } + + if !r.verifyEvent(&event) { + logger.Warnf("[Router Event] Potential spoofing attempt: invalid signature on event from %s", msg.ReceivedFrom) + continue + } + + eventTime := time.UnixMilli(event.Timestamp) + if time.Since(eventTime) > 5*time.Minute || time.Until(eventTime) > 5*time.Minute { + logger.Warnf("[Router Event] Dropping stale or future event from %s", msg.ReceivedFrom) + continue + } + + switch event.Type { + case api.MeshEvent_BANNED: + if event.PeerId != "" { + if bannedPeer, err := peer.Decode(event.PeerId); err == nil { + logger.Infof("[Router Event] Received BANNED event for peer %s, evicting from authenticated peers and adding to blocklist", bannedPeer) + r.authenticatedPeers.Delete(bannedPeer) + r.bannedPeers.Store(bannedPeer, true) + if r.Host != nil { + _ = r.Host.Network().ClosePeer(bannedPeer) + } + } + } + case api.MeshEvent_KEY_ROTATION: + logger.Infof("[Router Event] Received KEY_ROTATION event, triggering key sync") + if err := r.syncKeys(); err != nil { + logger.Warnf("[Router Event] Failed to sync keys after KEY_ROTATION event: %v", err) + } + } + } +} + func (r *Router) runKeysSyncLoop() { defer r.wg.Done() ticker := time.NewTicker(r.config.KeysSyncInterval) @@ -725,6 +814,12 @@ func (r *Router) HandleAuthHandshake(s network.Stream) { defer func() { _ = s.Close() }() remotePeer := s.Conn().RemotePeer() + if _, banned := r.bannedPeers.Load(remotePeer); banned { + logger.Warnf("[AuthN] Rejecting authentication for banned peer %s", remotePeer) + _ = s.Reset() + return + } + reader := msgio.NewVarintReaderSize(s, 1024*64) msg, err := reader.ReadMsg() if err != nil { @@ -769,6 +864,9 @@ func (r *Router) HandleAuthHandshake(s network.Stream) { // performMutualAuth initiates client-side mutual authentication handshake. func (r *Router) performMutualAuth(s network.Stream) error { remotePeer := s.Conn().RemotePeer() + if _, banned := r.bannedPeers.Load(remotePeer); banned { + return fmt.Errorf("peer %s is banned", remotePeer) + } _ = s.SetDeadline(time.Now().Add(5 * time.Second)) // Send our biscuit diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 7acb266f..149ac411 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -125,23 +125,23 @@ func setupControlPlane(t *testing.T, oidcIssuer string) (*controlplane.Server, s } // Bootstrap Policy granting 'router' role to group 'routers' - policy := &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{ - {Role: api.RoleRouter, Members: []string{"group:routers"}}, - {Role: "user-role", Members: []string{"group:users"}}, + roles := []*api.PolicyRole{ + { + Name: api.RoleRouter, + AllowedServices: []string{"*"}, + AllowedTargets: []string{"*"}, }, - Roles: map[string]api.RolePolicy{ - api.RoleRouter: { - AllowedServices: []string{"*"}, - AllowedTargets: []string{"*"}, - }, - "user-role": { - AllowedServices: []string{"mcp://read"}, - }, + { + Name: "user-role", + AllowedServices: []string{"mcp://read"}, }, } - if err := store.SavePolicy(context.Background(), policy); err != nil { + bindings := []*api.PolicyBinding{ + {Role: api.RoleRouter, Members: []string{"group:routers"}}, + {Role: "user-role", Members: []string{"group:users"}}, + } + + if err := store.SaveMeshPolicy(context.Background(), roles, bindings); err != nil { t.Fatalf("failed to save policy: %v", err) } @@ -171,6 +171,7 @@ func TestRouterIntegration(t *testing.T) { routerJWT := mintToken(map[string]interface{}{ "sub": "router-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) // 1. Create and Start Router @@ -327,11 +328,13 @@ func TestRouterFederation(t *testing.T) { router1JWT := mintToken(map[string]interface{}{ "sub": "router-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) // Mint token for Router 2 router2JWT := mintToken(map[string]interface{}{ "sub": "router-2", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) r1KeyPath := filepath.Join(tempDir, "router1.key") @@ -412,6 +415,7 @@ func TestRouterProactiveTokenRefresh(t *testing.T) { routerJWT := mintToken(map[string]interface{}{ "sub": "router-refresh-test", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) rOpts := Options{ @@ -472,6 +476,7 @@ func TestRouterLeaseRenewalReEnrollOn401(t *testing.T) { routerJWT := mintToken(map[string]interface{}{ "sub": "router-reenroll-test", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) rOpts := Options{ @@ -539,6 +544,7 @@ func TestRouterProactiveRefreshReEnrollOn401(t *testing.T) { routerJWT := mintToken(map[string]interface{}{ "sub": "router-refresh-401-test", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) rOpts := Options{ @@ -583,4 +589,98 @@ func TestRouterProactiveRefreshReEnrollOn401(t *testing.T) { } } +func TestRouterGossipSubBannedEvent(t *testing.T) { + issuer, mintToken := startCustomMockOIDC(t) + cp, cpStore, cpURL := setupControlPlane(t, issuer) + defer func() { + _ = cp.Close() + _ = cpStore.Close() + }() + + tempDir := t.TempDir() + routerKeyPath := filepath.Join(tempDir, "router.key") + + routerJWT := mintToken(map[string]interface{}{ + "sub": "router-banned-test", + "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, + }) + + rOpts := Options{ + ControlPlaneURL: cpURL, + ListenAddrs: []string{"/ip4/127.0.0.1/tcp/0"}, + KeysSyncInterval: 20 * time.Second, + LeaseRenewInterval: 20 * time.Second, + OIDCToken: routerJWT, + KeysDBPath: routerKeyPath, + AllowLoopback: true, + BiscuitTimeout: 1 * time.Second, + } + + r, err := NewRouter(context.Background(), rOpts) + if err != nil { + t.Fatalf("failed to create router: %v", err) + } + if err := r.Start(); err != nil { + t.Fatalf("failed to start router: %v", err) + } + defer func() { _ = r.Close() }() + + privNode, _, err := crypto.GenerateKeyPair(crypto.Ed25519, -1) + if err != nil { + t.Fatalf("failed to generate node keypair: %v", err) + } + bannedPeerID, err := peer.IDFromPrivateKey(privNode) + if err != nil { + t.Fatalf("failed to get peer ID from keypair: %v", err) + } + bannedPeerIDStr := bannedPeerID.String() + + // Manually insert bannedPeer into router's authenticatedPeers + r.authenticatedPeers.Store(bannedPeerID, true) + if _, ok := r.authenticatedPeers.Load(bannedPeerID); !ok { + t.Fatalf("failed to seed authenticatedPeers map") + } + // Get CP's signing key from store to sign the MeshEvent + cpPrivKey, _, err := cpStore.GetCurrentKey(context.Background()) + if err != nil { + t.Fatalf("failed to get CP key: %v", err) + } + + event := &api.MeshEvent{ + Type: api.MeshEvent_BANNED, + PeerId: bannedPeerIDStr, + Timestamp: time.Now().UnixMilli(), + } + eventData, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if err != nil { + t.Fatalf("failed to marshal event: %v", err) + } + event.Signature = ed25519.Sign(cpPrivKey, eventData) + signedData, err := proto.MarshalOptions{Deterministic: true}.Marshal(event) + if err != nil { + t.Fatalf("failed to marshal signed event: %v", err) + } + + // Poll until authenticatedPeers no longer contains bannedPeerID, + // periodically publishing in case GossipSub subscription goroutine was still starting. + deadline := time.Now().Add(5 * time.Second) + evicted := false + for time.Now().Before(deadline) { + _ = r.EventTopic.Publish(context.Background(), signedData) + if _, ok := r.authenticatedPeers.Load(bannedPeerID); !ok { + evicted = true + break + } + time.Sleep(50 * time.Millisecond) + } + + if !evicted { + t.Errorf("expected peer %s to be evicted from authenticatedPeers upon receiving MeshEvent_BANNED", bannedPeerIDStr) + } + + if _, banned := r.bannedPeers.Load(bannedPeerID); !banned { + t.Errorf("expected peer %s to be stored in bannedPeers blocklist upon receiving MeshEvent_BANNED", bannedPeerIDStr) + } +} diff --git a/internal/storage/sql_store.go b/internal/storage/sql_store.go index ed2f942e..020b24a5 100644 --- a/internal/storage/sql_store.go +++ b/internal/storage/sql_store.go @@ -25,7 +25,6 @@ import ( "github.com/google/sam/api" log "github.com/ipfs/go-log/v2" - "gopkg.in/yaml.v2" // Register PG and SQLite drivers _ "github.com/jackc/pgx/v5/stdlib" @@ -59,6 +58,22 @@ func NewSQLStore(driverName, dataSourceName string) (*SQLStore, error) { return nil, fmt.Errorf("failed to open database: %w", err) } + // For postgres, retry Ping() to allow DB container to finish booting + if actualDriver == "pgx" { + var pingErr error + for i := 0; i < 30; i++ { + pingErr = db.Ping() + if pingErr == nil { + break + } + time.Sleep(1 * time.Second) + } + if pingErr != nil { + _ = db.Close() + return nil, fmt.Errorf("failed to ping database: %w", pingErr) + } + } + store := &SQLStore{ db: db, driverName: driverName, @@ -224,6 +239,47 @@ var migrations = []migration{ `ALTER TABLE bootstrap_tokens ADD COLUMN owner_id TEXT REFERENCES users(id)`, }, }, + { + version: 4, + postgres: []string{ + `DROP TABLE IF EXISTS policies`, + `CREATE TABLE IF NOT EXISTS roles ( + name VARCHAR(64) PRIMARY KEY, + description TEXT, + created_at BIGINT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS role_permissions ( + id SERIAL PRIMARY KEY, + role_name VARCHAR(64) REFERENCES roles(name) ON DELETE CASCADE, + resource_type VARCHAR(64) NOT NULL, + resource_value TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS role_bindings ( + id SERIAL PRIMARY KEY, + role_name VARCHAR(64) REFERENCES roles(name) ON DELETE CASCADE, + member VARCHAR(255) NOT NULL + )`, + }, + sqlite: []string{ + `DROP TABLE IF EXISTS policies`, + `CREATE TABLE IF NOT EXISTS roles ( + name TEXT PRIMARY KEY, + description TEXT, + created_at BIGINT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS role_permissions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role_name TEXT REFERENCES roles(name) ON DELETE CASCADE, + resource_type TEXT NOT NULL, + resource_value TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS role_bindings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + role_name TEXT REFERENCES roles(name) ON DELETE CASCADE, + member TEXT NOT NULL + )`, + }, + }, } func (s *SQLStore) initSchema() error { @@ -251,6 +307,12 @@ func (s *SQLStore) initSchema() error { } defer func() { _ = tx.Rollback() }() + if s.isPostgres() { + if _, err := tx.Exec("SELECT pg_advisory_xact_lock(7345892)"); err != nil { + return fmt.Errorf("failed to acquire migration advisory lock: %w", err) + } + } + queries := m.sqlite if s.isPostgres() { queries = m.postgres @@ -335,9 +397,13 @@ func (s *SQLStore) GetAllValidKeys(ctx context.Context) ([]KeyPair, error) { if exp.Valid { expiration = time.UnixMilli(exp.Int64) } + privCopy := make([]byte, len(priv)) + copy(privCopy, priv) + pubCopy := make([]byte, len(pub)) + copy(pubCopy, pub) keys = append(keys, KeyPair{ - Private: ed25519.PrivateKey(priv), - Public: ed25519.PublicKey(pub), + Private: ed25519.PrivateKey(privCopy), + Public: ed25519.PublicKey(pubCopy), Expiration: expiration, }) } @@ -538,49 +604,142 @@ func (s *SQLStore) GetActiveRouters(ctx context.Context) ([]RouterLease, error) return leases, rows.Err() } -// SavePolicy implements Store. -func (s *SQLStore) SavePolicy(ctx context.Context, policy *api.PolicyConfig) error { - data, err := yaml.Marshal(policy) +// SaveMeshPolicy replaces the entire mesh policy with the provided roles and bindings. +func (s *SQLStore) SaveMeshPolicy(ctx context.Context, roles []*api.PolicyRole, bindings []*api.PolicyBinding) error { + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } + defer func() { _ = tx.Rollback() }() - var query string - if s.isPostgres() { - query = s.rebind(` - INSERT INTO policies (id, content, updated_at) - VALUES ('mesh-policy', ?, ?) - ON CONFLICT (id) - DO UPDATE SET content = EXCLUDED.content, updated_at = EXCLUDED.updated_at`) - } else { - query = s.rebind(` - INSERT INTO policies (id, content, updated_at) - VALUES ('mesh-policy', ?, ?) - ON CONFLICT (id) - DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`) + // For simplicity in replacing policy, we clear all and insert new. + if _, err := tx.ExecContext(ctx, "DELETE FROM role_permissions"); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM role_bindings"); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, "DELETE FROM roles"); err != nil { + return err } - _, err = s.db.ExecContext(ctx, query, string(data), time.Now().UnixMilli()) - return err + for _, r := range roles { + if r == nil { + continue + } + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO roles (name, description, created_at) VALUES (?, '', ?)"), r.Name, time.Now().UnixMilli()); err != nil { + return err + } + for _, target := range r.AllowedTargets { + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'target', ?)"), r.Name, target); err != nil { + return err + } + } + for _, svc := range r.AllowedServices { + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'service', ?)"), r.Name, svc); err != nil { + return err + } + } + for _, dl := range r.CustomDatalog { + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_permissions (role_name, resource_type, resource_value) VALUES (?, 'custom_datalog', ?)"), r.Name, dl); err != nil { + return err + } + } + } + + for _, b := range bindings { + if b == nil { + continue + } + for _, member := range b.Members { + if _, err := tx.ExecContext(ctx, s.rebind("INSERT INTO role_bindings (role_name, member) VALUES (?, ?)"), b.Role, member); err != nil { + return err + } + } + } + + return tx.Commit() } -// GetPolicy implements Store. -func (s *SQLStore) GetPolicy(ctx context.Context) (*api.PolicyConfig, error) { - query := s.rebind(`SELECT content FROM policies WHERE id = 'mesh-policy'`) - var data string - err := s.db.QueryRowContext(ctx, query).Scan(&data) - if err == sql.ErrNoRows { - return nil, ErrNotFound +// GetMeshPolicy retrieves the entire mesh policy as structured data. +func (s *SQLStore) GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api.PolicyBinding, error) { + rolesRows, err := s.db.QueryContext(ctx, s.rebind("SELECT name FROM roles")) + if err != nil { + return nil, nil, err } + defer func() { _ = rolesRows.Close() }() + + rolesMap := make(map[string]*api.PolicyRole) + var roles []*api.PolicyRole + + for rolesRows.Next() { + var name string + if err := rolesRows.Scan(&name); err != nil { + return nil, nil, err + } + role := &api.PolicyRole{Name: name} + rolesMap[name] = role + roles = append(roles, role) + } + if err := rolesRows.Err(); err != nil { + return nil, nil, err + } + + permsRows, err := s.db.QueryContext(ctx, s.rebind("SELECT role_name, resource_type, resource_value FROM role_permissions")) if err != nil { - return nil, err + return nil, nil, err } + defer func() { _ = permsRows.Close() }() - var policy api.PolicyConfig - if err := yaml.Unmarshal([]byte(data), &policy); err != nil { - return nil, err + for permsRows.Next() { + var roleName, resType, resValue string + if err := permsRows.Scan(&roleName, &resType, &resValue); err != nil { + return nil, nil, err + } + if r, ok := rolesMap[roleName]; ok { + switch resType { + case "target": + r.AllowedTargets = append(r.AllowedTargets, resValue) + case "service": + r.AllowedServices = append(r.AllowedServices, resValue) + case "custom_datalog": + r.CustomDatalog = append(r.CustomDatalog, resValue) + } + } } - return &policy, nil + if err := permsRows.Err(); err != nil { + return nil, nil, err + } + + bindingsRows, err := s.db.QueryContext(ctx, s.rebind("SELECT role_name, member FROM role_bindings")) + if err != nil { + return nil, nil, err + } + defer func() { _ = bindingsRows.Close() }() + + bindingsMap := make(map[string]*api.PolicyBinding) + for bindingsRows.Next() { + var roleName, member string + if err := bindingsRows.Scan(&roleName, &member); err != nil { + return nil, nil, err + } + b, ok := bindingsMap[roleName] + if !ok { + b = &api.PolicyBinding{Role: roleName} + bindingsMap[roleName] = b + } + b.Members = append(b.Members, member) + } + if err := bindingsRows.Err(); err != nil { + return nil, nil, err + } + + var bindings []*api.PolicyBinding + for _, b := range bindingsMap { + bindings = append(bindings, b) + } + + return roles, bindings, nil } // SaveBootstrapToken persists a new bootstrap token. @@ -725,6 +884,15 @@ func (s *SQLStore) scanEnrollmentRequest(row scannable) (*EnrollmentRequest, err t := time.Unix(resAt.Int64, 0) req.ResolvedAt = &t } + + pubCopy := make([]byte, len(req.PublicKey)) + copy(pubCopy, req.PublicKey) + req.PublicKey = pubCopy + + biscuitCopy := make([]byte, len(req.BiscuitToken)) + copy(biscuitCopy, req.BiscuitToken) + req.BiscuitToken = biscuitCopy + return &req, nil } @@ -805,6 +973,15 @@ func (s *SQLStore) ListNodes(ctx context.Context) ([]EnrolledNode, error) { } node.EnrolledAt = time.UnixMilli(enrolledAtUnix) node.ExpiresAt = time.UnixMilli(expiresAtUnix) + + pubCopy := make([]byte, len(node.PublicKey)) + copy(pubCopy, node.PublicKey) + node.PublicKey = pubCopy + + biscuitCopy := make([]byte, len(node.Biscuit)) + copy(biscuitCopy, node.Biscuit) + node.Biscuit = biscuitCopy + nodes = append(nodes, node) } if err := rows.Err(); err != nil { diff --git a/internal/storage/sql_store_test.go b/internal/storage/sql_store_test.go index c40fa5e7..2a166266 100644 --- a/internal/storage/sql_store_test.go +++ b/internal/storage/sql_store_test.go @@ -285,37 +285,35 @@ func TestPolicyOps(t *testing.T) { ctx := context.Background() - // Policy should be empty - _, err := store.GetPolicy(ctx) - if err != ErrNotFound { - t.Fatalf("expected ErrNotFound, got %v", err) + // Policy should be empty initially + roles, bindings, err := store.GetMeshPolicy(ctx) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if len(roles) != 0 || len(bindings) != 0 { + t.Fatalf("expected empty policy initially, got %d roles and %d bindings", len(roles), len(bindings)) } // Save policy - p := &api.PolicyConfig{ - Version: "v1alpha1", - Bindings: []api.Binding{ - {Role: "admin", Members: []string{"user:alice"}}, - }, - Roles: map[string]api.RolePolicy{ - "admin": { - AllowedServices: []string{"*"}, - AllowedTargets: []string{"*"}, - }, - }, - } - err = store.SavePolicy(ctx, p) + p := &api.PolicyRole{ + Name: "admin", + AllowedServices: []string{"*"}, + AllowedTargets: []string{"*"}, + } + err = store.SaveMeshPolicy(ctx, []*api.PolicyRole{p}, []*api.PolicyBinding{ + {Role: "admin", Members: []string{"user:alice"}}, + }) if err != nil { t.Fatalf("failed to save policy: %v", err) } // Get policy - retPolicy, err := store.GetPolicy(ctx) + retRoles, retBindings, err := store.GetMeshPolicy(ctx) if err != nil { t.Fatalf("failed to get policy: %v", err) } - if retPolicy.Version != p.Version || len(retPolicy.Bindings) != 1 || retPolicy.Bindings[0].Role != "admin" { - t.Fatalf("policy contents mismatch: %+v", retPolicy) + if len(retRoles) != 1 || retRoles[0].Name != "admin" || len(retBindings) != 1 || retBindings[0].Role != "admin" { + t.Fatalf("policy contents mismatch") } } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 0c6c1314..0343c78f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -133,11 +133,11 @@ type Store interface { // GetActiveRouters retrieves all routers whose leases are still valid. GetActiveRouters(ctx context.Context) ([]RouterLease, error) - // SavePolicy persists the mesh configurations. - SavePolicy(ctx context.Context, policy *api.PolicyConfig) error + // SaveMeshPolicy persists the mesh configurations. + SaveMeshPolicy(ctx context.Context, roles []*api.PolicyRole, bindings []*api.PolicyBinding) error - // GetPolicy loads the mesh configurations. - GetPolicy(ctx context.Context) (*api.PolicyConfig, error) + // GetMeshPolicy loads the mesh configurations. + GetMeshPolicy(ctx context.Context) ([]*api.PolicyRole, []*api.PolicyBinding, error) // SaveBootstrapToken persists a new bootstrap token. SaveBootstrapToken(ctx context.Context, token *BootstrapToken) error diff --git a/mobile/mobile_e2e.sh b/mobile/mobile_e2e.sh index 50421514..39e41017 100755 --- a/mobile/mobile_e2e.sh +++ b/mobile/mobile_e2e.sh @@ -71,7 +71,6 @@ docker run --name sam-control-plane \ --network sam-net \ -p 37001:37001 \ -v /tmp/control-plane-data:/data \ - -v "$REPO_ROOT/tests/e2e/fixtures/default-policy.yaml:/policy.yaml" \ -d --rm \ sam-control-plane:local \ --bind-address 0.0.0.0:37001 \ @@ -79,7 +78,7 @@ docker run --name sam-control-plane \ --db-dsn /data/control-plane.db \ --issuer http://mock-oidc:18080 \ --allowed-audiences sam-mesh-audience,sam-hub-audience \ - --policy-file /policy.yaml \ + --admin-token secret-admin-token \ --insecure-skip-tls-verify \ --log-level debug @@ -109,6 +108,23 @@ adb reverse tcp:37002 tcp:37002 # Wait for Control Plane to be ready timeout 15s bash -c 'until curl -s http://127.0.0.1:37001/info >/dev/null; do sleep 0.5; done' +# Seed initial mesh policy via REST API +curl -s -X POST \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer secret-admin-token" \ + -d '{ + "roles": [ + {"name": "admin", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:router", "allowed_services": ["*"], "allowed_targets": ["*"]}, + {"name": "sam:role:node", "allowed_services": ["*"], "allowed_targets": ["*"]} + ], + "bindings": [ + {"role": "admin", "members": ["sam:system:authenticated"]}, + {"role": "sam:role:router", "members": ["group:routers"]} + ] + }' \ + http://127.0.0.1:37001/policies + # 5. Enroll and Start the External Node on Host inside Docker rm -rf /tmp/host-node-data mkdir -p /tmp/host-node-data diff --git a/sam-mcp-python/pyproject.toml b/sam-mcp-python/pyproject.toml index eda7a8b3..1c5e374f 100644 --- a/sam-mcp-python/pyproject.toml +++ b/sam-mcp-python/pyproject.toml @@ -10,6 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dependencies = [ "mcp>=1.0.0", + "httpx>=0.20.0", ] [project.optional-dependencies] diff --git a/sam-mcp-python/src/sam_mcp/client.py b/sam-mcp-python/src/sam_mcp/client.py index 952c9fbf..c35f8193 100644 --- a/sam-mcp-python/src/sam_mcp/client.py +++ b/sam-mcp-python/src/sam_mcp/client.py @@ -27,7 +27,8 @@ async def connect(self): self._http_client = httpx.AsyncClient(headers=headers) try: self._sh_cm = streamable_http_client(self.server_url, http_client=self._http_client) - read_stream, write_stream, _get_session_id = await self._sh_cm.__aenter__() + res = await self._sh_cm.__aenter__() + read_stream, write_stream = res[0], res[1] self.session = ClientSession(read_stream, write_stream) await self.session.__aenter__() await self.session.initialize() diff --git a/site/content/docs/development/policy.md b/site/content/docs/development/policy.md index 700a7ceb..00ebe63d 100644 --- a/site/content/docs/development/policy.md +++ b/site/content/docs/development/policy.md @@ -71,33 +71,40 @@ Because the OIDC claims were translated into Datalog, a node administrator can w allow if group("engineering"); ``` -## 2. Hub Policy Schema (`policies.yaml`) -Admins define central permissions by mapping OIDC roles to specific capabilities. - -* **`allowed_targets`**: Defines which logical groups or specific peers a user can route messages to, analogous to Active Directory security groups. Target definitions must be formatted as resolved facts (e.g., `group:`, `user:`, `email:`, `role:`, or `node:`). *Note: These are evaluated dynamically at the destination node using its own identity (see Section 3.1).* -* **`allowed_services`**: Defines the application-level tools or endpoints a user can access. Services use a strict `type://name` convention (e.g., `mcp://db-agent` or `inference://openrouter`). - * **Strict Namespaces**: There are no implicit fallbacks. `system://...` is used for internal services, `mcp://...` for node services, `inference://...` for AI models, etc. Service names must be valid domain labels (e.g. `value1.value2.value3`). - * **Wildcards**: SAM natively supports domain-level wildcards to build complex policies. The hub generates specific facts like `granted_service_exact($type, $name)`, `granted_service_prefix($type, $prefix)`, `granted_service_suffix($type, $suffix)`, `granted_service_all_in_type($type)`, or `granted_service_all_types()`. You can grant access to an entire type via `mcp://*`, allow prefix-based wildcard matching like `mcp://*.service.local` (which matches services ending in `.service.local`), suffix-based wildcard matching like `mcp://service.*` (which matches services starting with `service.`), or global access to everything via `*`. Note that arbitrary partial matches (e.g., `dev-*` or `*-prod`) are not supported because they do not align with domain boundaries and will fail DNS name validation. - * **MCP Namespace Convention**: The `mcp://` prefix (`api.MCPServicePrefix`) is the explicit convention for all Model Context Protocol targets. When remote nodes query local nodes for tool catalogs, if the local service is an MCP server, the proxy layer strictly enforces the authorization policy against the `mcp://` prefix. -> [!NOTE] -> The `*` global wildcard is a special case. It grants `granted_service_all_types()` fact, allowing the caller to invoke any tool regardless of its namespace or name. - -```yaml -version: "v1alpha1" -roles: - data-scientist: - allowed_targets: - - "node:12D3KooW..." # Specific peer ID - - "group:backend-nodes" # Logical group of peers - - "role:admin" # Nodes possessing the admin role - - "user:auth0|123456" # Node bound to a specific user sub - - "email:db@example.com" # Node bound to a specific email - allowed_services: - - "mcp://db-agent" # Access to specific MCP server - - "inference://openrouter" # Access to inference endpoints - - "mcp://*" # Wildcard access to all MCP servers - custom_datalog: - - 'department("analytics");' # Raw injected facts +## 2. Hub Policy Configuration (REST API) +Admins manage central permissions dynamically via the Control Plane REST API. The policy database defines a set of **Roles** and **Bindings**. + +* **Roles**: Define specific capabilities (allowed destinations and services). + * `allowed_targets`: Defines which logical groups or specific peers a user can route messages to, analogous to Active Directory security groups. Target definitions must be formatted as resolved facts (e.g., `group:`, `user:`, `email:`, `role:`, or `node:`). *Note: These are evaluated dynamically at the destination node using its own identity (see Section 3.1).* + * `allowed_services`: Defines the application-level tools or endpoints a user can access. Services use a strict `type://name` convention (e.g., `mcp://db-agent` or `inference://openrouter`). + * **Strict Namespaces**: There are no implicit fallbacks. `system://...` is used for internal services, `mcp://...` for node services, `inference://...` for AI models, etc. Service names must be valid domain labels (e.g. `value1.value2.value3`). + * **Wildcards**: SAM natively supports domain-level wildcards to build complex policies. The hub generates specific facts like `granted_service_exact($type, $name)`, `granted_service_prefix($type, $prefix)`, `granted_service_suffix($type, $suffix)`, `granted_service_all_in_type($type)`, or `granted_service_all_types()`. You can grant access to an entire type via `mcp://*`, allow prefix-based wildcard matching like `mcp://*.service.local` (which matches services ending in `.service.local`), suffix-based wildcard matching like `mcp://service.*` (which matches services starting with `service.`), or global access to everything via `*`. + * **MCP Namespace Convention**: The `mcp://` prefix (`api.MCPServicePrefix`) is the explicit convention for all Model Context Protocol targets. +* **Bindings**: Map OIDC identities (sub/user, email, group) to specific Roles. + +### 2.1 Updating Mesh Policy +Admins POST policy JSON updates to the Control Plane `/policies` endpoint using the admin authorization token: + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "roles": [ + { + "name": "data-scientist-role", + "allowed_targets": ["group:backend-nodes", "node:12D3KooW..."], + "allowed_services": ["mcp://db-agent", "inference://openrouter", "mcp://*"] + } + ], + "bindings": [ + { + "role": "data-scientist-role", + "members": ["group:data-science-team"] + } + ] + }' \ + http://:8080/policies ``` ## 3. Node Local Policy Schema (`sam-node-config.yaml`) @@ -210,68 +217,53 @@ graph TD Here is a representative configuration for an engineering mesh deployment. It shows how to use roles, target groups, service namespaces, wildcards, and local attenuation checks to build a zero-trust development network. -### 6.1 Central Hub Policy (`policies.yaml`) - -Deploy this file on `sam-control-plane` to define global roles and user/service-account mappings. +### 6.1 Central Hub Policy API Update -```yaml -version: "v1alpha1" +Send this JSON payload via `POST /policies` to define global roles and user/service-account mappings. -# Bindings map OIDC identities (sub/user, email, groups) to SAM Roles. -# Note: Kubernetes projected service account tokens do not carry 'groups' claims, -# so they must be bound explicitly using their 'user' claim format. -bindings: - # 1. Global Admins (Infrastructure SAs, Lead Architects) - - members: ["user:system:serviceaccount:sam-mesh:admin-sa"] - role: "admin" - - members: ["group:infrastructure-leads"] - role: "admin" - - # 2. Software Developers - - members: ["group:software-engineering-team"] - role: "developer" - - # 3. Data Scientists & AI Engineers - - members: ["group:data-science-team"] - role: "data-scientist" - - # 4. Contractors / Read-Only Audits - - members: ["email:audit-contractor@external.com"] - role: "auditor" - -# Roles define the allowed destinations (allowed_targets) and tools (allowed_services) -roles: - # Admins have full, unrestricted access to the entire mesh - admin: - allowed_targets: - - "*" - allowed_services: - - "*" - - # Developers can call development tools on dev nodes - developer: - allowed_targets: - - "group:dev-nodes" # Can only call nodes in the 'dev-nodes' target group - allowed_services: - - "mcp://code-reviewer" # Can call the code reviewer tool - - "mcp://git-helper" # Can call git helper tools - - "mcp://build-runner.*" # Wildcard: matches any build runner sub-service (e.g. build-runner.go) - - # Data Scientists can call database tools and all AI inference endpoints - data-scientist: - allowed_targets: - - "group:data-nodes" # Can call nodes in the 'data-nodes' target group - - "node:12D3KooWSpecialNode" # Can call a specific high-compute node directly - allowed_services: - - "mcp://db-reader" # Can query databases - - "inference://*" # Wildcard: can access any LLM inference service - - # Auditors can only query metadata catalogs and cannot call operational tools - auditor: - allowed_targets: - - "*" - allowed_services: - - "system://sam.catalog" # Strictly limited to tool discovery/metadata +```json +{ + "roles": [ + { + "name": "admin", + "allowed_targets": ["*"], + "allowed_services": ["*"] + }, + { + "name": "developer", + "allowed_targets": ["group:dev-nodes"], + "allowed_services": ["mcp://code-reviewer", "mcp://git-helper", "mcp://build-runner.*"] + }, + { + "name": "data-scientist", + "allowed_targets": ["group:data-nodes", "node:12D3KooWSpecialNode"], + "allowed_services": ["mcp://db-reader", "inference://*"] + }, + { + "name": "auditor", + "allowed_targets": ["*"], + "allowed_services": ["system://sam.catalog"] + } + ], + "bindings": [ + { + "role": "admin", + "members": ["user:system:serviceaccount:sam-mesh:admin-sa", "group:infrastructure-leads"] + }, + { + "role": "developer", + "members": ["group:software-engineering-team"] + }, + { + "role": "data-scientist", + "members": ["group:data-science-team"] + }, + { + "role": "auditor", + "members": ["email:audit-contractor@external.com"] + } + ] +} ``` ### 6.2 Node-Level Configuration (`sam-node-config.yaml`) diff --git a/site/content/docs/user/control-plane-configuration.md b/site/content/docs/user/control-plane-configuration.md index a4273533..60aafc1c 100644 --- a/site/content/docs/user/control-plane-configuration.md +++ b/site/content/docs/user/control-plane-configuration.md @@ -21,7 +21,6 @@ The Control Plane is responsible for bridging user identities from trusted OIDC | `--db-driver` | *None* | `sqlite` | Database driver (`sqlite` or `postgres`). | | `--db-dsn` | *None* | `control-plane.db` | Database DSN/Connection URL (e.g. `postgres://user:pass@host:5432/db`). | | `--allowed-audiences` | *None* | `sam-mesh-audience` | Comma-separated list of allowed JWT audiences. | -| `--policy-file` | *None* | `policies.yaml` | Path to the YAML file defining authorization roles and bindings (bootstrapping only). | | `--admin-token` | *None* | *None* | Secret token string required in the HTTP Header `Authorization: Bearer ` for admin operations. | | `--insecure-skip-tls-verify` | *None* | `false` | Set to `true` to skip certificate validation for development/testing OIDC providers. | | `--key-rotation-interval` | *None* | `24h` | Key rotation interval (e.g. `24h`). `0s` disables rotation. | @@ -50,50 +49,42 @@ The Router is a dedicated GossipSub helper that maintains stable network address --- -## 3. Configuring Role-Based Policies (`policies.yaml`) +## 3. Configuring Role-Based Policies (Dynamic API) -The Control Plane dynamically issues permissions inside the Biscuit token based on identity claims (users or groups) mapped to specific roles in the policy file. +The Control Plane dynamically issues permissions inside the Biscuit token based on identity claims (users or groups) mapped to specific roles in the database. The policy defines what endpoints and services agents are permitted to use: * **`allowed_targets`**: Restricts which logical endpoints the agent can route connections to. Use resolved Biscuit facts: `group:`, `user:`, `email:`, `role:`, or `node:`. * **`allowed_services`**: Restricts the application-level services the agent can invoke. Services are prefixed by their protocol type and URI scheme (e.g., `mcp://local-shell-tools` or `inference://openrouter`). Wildcards are supported (e.g., `mcp://*`). -### Example Policy Mapping -Create a `policies.yaml` file in the directory where you run `sam-control-plane`: - -```yaml -version: v1alpha1 - -# Define authorization roles and their specific network/tool permissions -roles: - developer-role: - allowed_targets: - - "group:dev-nodes" - - "email:dev-lead@example.com" - - "user:auth0|123456" - - "node:12D3KooWSpecificDevNodeId" - allowed_services: - - "mcp://local-shell-tools" - - "mcp://git-helper" - - "inference://openrouter" - - admin-role: - allowed_targets: - - "group:all-nodes" - - "role:admin" - allowed_services: - - "mcp://*" - - "inference://*" - - "system://*" - -# Bind OIDC user subs, emails, or group claims to roles -bindings: - - members: ["email:alice@example.com"] - role: "admin-role" - - members: ["user:auth0|123456"] - role: "admin-role" - - members: ["group:eng-team"] - role: "developer-role" +### Seeding Policies via REST API +Admins manage policies by sending a JSON payload to the `/policies` endpoint. + +```json +{ + "roles": [ + { + "name": "developer-role", + "allowed_targets": ["group:dev-nodes", "email:dev-lead@example.com", "user:auth0|123456", "node:12D3KooWSpecificDevNodeId"], + "allowed_services": ["mcp://local-shell-tools", "mcp://git-helper", "inference://openrouter"] + }, + { + "name": "admin-role", + "allowed_targets": ["group:all-nodes", "role:admin"], + "allowed_services": ["mcp://*", "inference://*", "system://*"] + } + ], + "bindings": [ + { + "role": "admin-role", + "members": ["email:alice@example.com", "user:auth0|123456"] + }, + { + "role": "developer-role", + "members": ["group:eng-team"] + } + ] +} ``` ### 3.1 Binary Capability Roles (Zero Trust) @@ -104,14 +95,19 @@ To enforce the principle of least privilege, the Sovereign Agent Mesh implements The Hub control plane validates that the enrolling client is authorized for the requested role before issuing the Biscuit identity token: 1. **Node Fallback**: By default, any successfully authenticated OIDC identity or generic bootstrap token can claim the `sam:role:node` role. -2. **Explicit Bindings**: The higher-privilege capability roles `sam:role:router` and `sam:role:sambox` must be explicitly authorized to the user/service account by mapping the role name in `policies.yaml` bindings. For example: - -```yaml -bindings: - - members: ["group:mesh-routers"] - role: "sam:role:router" - - members: ["user:gateway-pod-sa"] - role: "sam:role:sambox" +2. **Explicit Bindings**: The higher-privilege capability roles `sam:role:router` and `sam:role:sambox` must be explicitly authorized to the user/service account by mapping the role name in bindings. For example: + +```json +"bindings": [ + { + "role": "sam:role:router", + "members": ["group:mesh-routers"] + }, + { + "role": "sam:role:sambox", + "members": ["user:gateway-pod-sa"] + } +] ``` If a binary requests a capability role it is not authorized for, enrollment fails immediately. If a client attempts to start using a token that lacks its binary's required role, startup aborts. @@ -123,15 +119,24 @@ If a binary requests a capability role it is not authorized for, enrollment fail Here is a script demonstrating how to boot both services in a secure development environment: ```bash -# 1. Start the Control Plane (using SQLite and policies) +# 1. Start the Control Plane (using SQLite) ./bin/sam-control-plane \ --issuer "https://accounts.google.com" \ --allowed-audiences "my-google-client-id.apps.googleusercontent.com" \ - --policy-file "./policies.yaml" \ --bind-address "0.0.0.0:8080" \ --admin-token "super-secret-admin-token" -# 2. In another shell, start the Router using a bootstrap JWT or token +# 2. Seed baseline policy via REST API +curl -X POST \ + -H "Authorization: Bearer super-secret-admin-token" \ + -H "Content-Type: application/json" \ + -d '{ + "roles": [{"name": "sam:role:node", "allowed_services": ["mcp://*"], "allowed_targets": ["*"]}], + "bindings": [{"role": "sam:role:node", "members": ["group:developers"]}] + }' \ + http://127.0.0.1:8080/policies + +# 3. In another shell, start the Router using a bootstrap JWT or token ./bin/sam-router \ --control-plane "http://127.0.0.1:8080" \ --listen "/ip4/0.0.0.0/tcp/5001" \ diff --git a/tests/e2e/docker/calc-mcp/requirements.txt b/tests/e2e/docker/calc-mcp/requirements.txt index c413b608..7ba9cdc1 100644 --- a/tests/e2e/docker/calc-mcp/requirements.txt +++ b/tests/e2e/docker/calc-mcp/requirements.txt @@ -1 +1 @@ -mcp>=1.0.0 +mcp[cli]>=1.0.0,<2.0.0 diff --git a/tests/e2e/docker/mock_oidc.py b/tests/e2e/docker/mock_oidc.py index 7c42c077..7d6a36ad 100644 --- a/tests/e2e/docker/mock_oidc.py +++ b/tests/e2e/docker/mock_oidc.py @@ -106,19 +106,23 @@ def do_POST(self): client_id = params.get('client_id', [''])[0] - # Assign groups based on client_id + # Assign groups and roles based on client_id groups = ['data-scientist'] + roles = ['sam:role:node'] if client_id == 'admin-client': groups = ['admin'] + roles = ['sam:role:admin'] elif client_id == 'router-client': groups = ['routers'] + roles = ['sam:role:router'] payload = { 'iss': ISSUER, 'aud': 'sam-mesh-audience', 'sub': 'test-user', 'exp': int(time.time()) + 3600, - 'groups': groups + 'groups': groups, + 'roles': roles } token = jwt.encode(payload, PRIVATE_KEY, algorithm='RS256', headers={'kid': 'test-key-id'}) body = { diff --git a/tests/e2e/find_remote_tools.bats b/tests/e2e/find_remote_tools.bats index d4cd1c54..1ad06e61 100644 --- a/tests/e2e/find_remote_tools.bats +++ b/tests/e2e/find_remote_tools.bats @@ -102,7 +102,7 @@ teardown() { mesh_start_node 2 \ "--log-level debug" \ "tests/e2e/docker/calc-mcp/sam-node-config.yaml" - mesh_wait_for_log "${MESH_PREFIX}-node-2" "SAM Node Online" 20 + mesh_wait_for_log "${MESH_PREFIX}-node-2" "SAM Node Online" 60 mesh_wait_for_mcp_ready 2 20 local node2_peer_id diff --git a/tests/e2e/fixtures/control-plane.yaml b/tests/e2e/fixtures/control-plane.yaml deleted file mode 100644 index 682a8517..00000000 --- a/tests/e2e/fixtures/control-plane.yaml +++ /dev/null @@ -1,150 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: sam-hub-config -data: - SAM_OIDC_ISSUER: "${ISSUERS}" - SAM_OIDC_ID: "sam-mesh-audience" - policy.yaml: | - version: v1alpha1 - bindings: - - members: ["user:system:serviceaccount:default:sam-node-sa"] - role: admin - - members: ["group:data-scientist"] - role: mesh-member - - members: ["group:admin"] - role: admin - - members: ["user:admin-user"] - role: admin - - members: ["group:routers"] - role: "sam:role:router" - roles: - admin: - allowed_services: - - "*" - allowed_targets: - - "*" - mesh-member: - allowed_services: - - "mcp://calculator" - - "mcp://db-agent" - - "mcp://http-tool" - - "mcp://stdio-tool" - - "system://sam.catalog" - allowed_targets: - - "*" - "sam:role:router": - allowed_services: - - "*" - allowed_targets: - - "*" ---- -apiVersion: v1 -kind: Service -metadata: - name: sam-db -spec: - ports: - - name: postgres - port: 5432 - targetPort: 5432 - selector: - app: sam-db ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sam-db -spec: - replicas: 1 - selector: - matchLabels: - app: sam-db - template: - metadata: - labels: - app: sam-db - spec: - containers: - - name: postgres - image: postgres:16-alpine - env: - - name: POSTGRES_DB - value: sam_mesh - - name: POSTGRES_USER - value: sam - - name: POSTGRES_PASSWORD - value: sam-secret-password - ports: - - containerPort: 5432 - protocol: TCP ---- -apiVersion: v1 -kind: Service -metadata: - name: sam-control-plane -spec: - ports: - - name: http - port: 8080 - targetPort: 8080 - selector: - app: sam-control-plane ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sam-control-plane -spec: - replicas: 2 - strategy: - type: Recreate - selector: - matchLabels: - app: sam-control-plane - template: - metadata: - labels: - app: sam-control-plane - spec: - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: app - operator: In - values: - - sam-control-plane - topologyKey: "kubernetes.io/hostname" - initContainers: - - name: wait-for-db - image: busybox:latest - command: ['sh', '-c', 'until nc -z -w3 sam-db 5432; do echo waiting for db; sleep 1; done'] - containers: - - name: sam-control-plane - image: sam-control-plane:local - ports: - - containerPort: 8080 - hostPort: 8080 - protocol: TCP - args: - - "--bind-address=0.0.0.0:8080" - - "--db-driver=postgres" - - "--db-dsn=postgres://sam:sam-secret-password@sam-db:5432/sam_mesh?sslmode=disable" - - "--issuer=${ISSUERS}" - - "--allowed-audiences=sam-mesh-audience,sam-hub-audience" - - "--policy-file=/etc/sam/policy.yaml" - - "--insecure-skip-tls-verify" - - "--lease-duration=15s" - - "--admin-token=super-secret-admin-token" - - "--auto-approve-enrollment" - - "--log-level=debug" - volumeMounts: - - name: config-volume - mountPath: /etc/sam/policy.yaml - subPath: policy.yaml - volumes: - - name: config-volume - configMap: - name: sam-hub-config diff --git a/tests/e2e/fixtures/default-policy.yaml b/tests/e2e/fixtures/default-policy.yaml deleted file mode 100644 index ebcd4e1f..00000000 --- a/tests/e2e/fixtures/default-policy.yaml +++ /dev/null @@ -1,17 +0,0 @@ -version: v1alpha1 -bindings: - - members: ["sam:system:authenticated"] - role: admin - - members: ["group:routers"] - role: "sam:role:router" -roles: - admin: - allowed_services: - - "*" - allowed_targets: - - "*" - "sam:role:router": - allowed_services: - - "*" - allowed_targets: - - "*" diff --git a/tests/e2e/fixtures/router.yaml b/tests/e2e/fixtures/router.yaml deleted file mode 100644 index af267739..00000000 --- a/tests/e2e/fixtures/router.yaml +++ /dev/null @@ -1,78 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: sam-router -spec: - clusterIP: None - ports: - - port: 4501 - targetPort: 4501 - protocol: TCP - name: p2p-tcp - - port: 4501 - targetPort: 4501 - protocol: UDP - name: p2p-udp - selector: - app: sam-router ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: sam-router -spec: - serviceName: "sam-router" - replicas: 1 - selector: - matchLabels: - app: sam-router - template: - metadata: - labels: - app: sam-router - spec: - containers: - - name: sam-router - image: sam-router:local - ports: - - containerPort: 4501 - hostPort: 4501 - protocol: TCP - - containerPort: 4501 - hostPort: 4501 - protocol: UDP - env: - - name: ROUTER_NODE_IP - valueFrom: - fieldRef: - fieldPath: status.hostIP - args: - - "--control-plane=http://sam-control-plane:8080" - - "--listen=/ip4/0.0.0.0/tcp/4501" - - "--listen=/ip4/0.0.0.0/udp/4501/quic-v1" - - "--external-addr=/dns4/sam-router/tcp/4501" - - "--external-addr=/dns4/sam-router/udp/4501/quic-v1" - - "--external-addr=/ip4/$(ROUTER_NODE_IP)/tcp/4501" - - "--external-addr=/ip4/$(ROUTER_NODE_IP)/udp/4501/quic-v1" - - "--bootstrap-token-path=/var/run/secrets/tokens/sa-token" - - "--keys-path=/data/router.key" - - "--allow-loopback" - - "--lease-renew-interval=5s" - - "--log-level=debug" - - "--dht-provider-addr-ttl=5s" - - "--dht-max-record-age=5s" - volumeMounts: - - name: router-data - mountPath: /data - - name: router-token-vol - mountPath: /var/run/secrets/tokens - readOnly: true - volumes: - - name: router-data - emptyDir: {} - - name: router-token-vol - secret: - secretName: sam-router-token - items: - - key: token - path: sa-token diff --git a/tests/e2e/fixtures/sam-node.yaml b/tests/e2e/fixtures/sam-node.yaml deleted file mode 100644 index aa201efe..00000000 --- a/tests/e2e/fixtures/sam-node.yaml +++ /dev/null @@ -1,39 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: sam-node -spec: - replicas: 2 - selector: - matchLabels: - app: sam-node - template: - metadata: - labels: - app: sam-node - spec: - serviceAccountName: sam-node-sa - containers: - - name: sam-node - image: sam-node:local - command: ["/sam-node", "run"] - args: - - "--hub" - - "http://sam-control-plane.default.svc.cluster.local:8080" - - "--jwt-path" - - "/var/run/secrets/tokens/sam-token" - - "--api-token" - - "secret-token" - - "--allow-loopback" - volumeMounts: - - name: sam-token - mountPath: /var/run/secrets/tokens - readOnly: true - volumes: - - name: sam-token - projected: - sources: - - serviceAccountToken: - path: sam-token - expirationSeconds: 3600 - audience: "sam-mesh-audience" diff --git a/tests/e2e/lib/container_mesh.bash b/tests/e2e/lib/container_mesh.bash index 1ce3a28b..0b5982bb 100644 --- a/tests/e2e/lib/container_mesh.bash +++ b/tests/e2e/lib/container_mesh.bash @@ -261,41 +261,31 @@ if [[ -z "${MESH_HELPERS_LOADED:-}" ]]; then local oidc_node_ip oidc_node_ip=$(docker inspect -f "{{(index .NetworkSettings.Networks \"${MESH_NETWORK:-kind}\").IPAddress}}" "${oidc_node}") - envsubst '$ISSUERS' < tests/e2e/fixtures/control-plane.yaml | kubectl --context="${KUBECONTEXT}" apply -f - - kubectl --context="${KUBECONTEXT}" rollout status deployment/sam-db --timeout=60s - kubectl --context="${KUBECONTEXT}" rollout status deployment/sam-control-plane --timeout=60s - - # Create curl pod in background to request token - kubectl --context="${KUBECONTEXT}" run curl-token-gen \ - --image=curlimages/curl:8.6.0 \ - --restart=Never \ - --overrides='{"spec": {"activeDeadlineSeconds": 30}}' \ - -- \ - curl -s -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer super-secret-admin-token" \ - -d '{"role": "sam:role:router", "max_usages": 999999}' \ - http://sam-control-plane:8080/admin/bootstrap-tokens - - if ! kubectl --context="${KUBECONTEXT}" wait --for=jsonpath='{.status.phase}'=Succeeded pod/curl-token-gen --timeout=15s; then - echo "ERROR: Token generation pod failed! Diagnostics:" - kubectl --context="${KUBECONTEXT}" describe pod curl-token-gen || true - kubectl --context="${KUBECONTEXT}" logs pod/curl-token-gen || true - kubectl --context="${KUBECONTEXT}" delete pod curl-token-gen --ignore-not-found || true - exit 1 + local helm_bin="helm" + if ! command -v helm >/dev/null 2>&1; then + if [[ -x "./bin/helm" ]]; then + helm_bin="./bin/helm" + else + echo "helm CLI not found; please install helm or place it in ./bin/helm" >&2 + return 1 + fi fi - local token_json - token_json=$(kubectl --context="${KUBECONTEXT}" logs pod/curl-token-gen) - kubectl --context="${KUBECONTEXT}" delete pod curl-token-gen --ignore-not-found - - local router_token - router_token=$(echo "${token_json}" | jq -r .token) - [[ -n "${router_token}" && "${router_token}" != "null" ]] - - kubectl --context="${KUBECONTEXT}" create secret generic sam-router-token --from-literal=token="${router_token}" --dry-run=client -o yaml | kubectl --context="${KUBECONTEXT}" apply -f - - - kubectl --context="${KUBECONTEXT}" apply -f tests/e2e/fixtures/router.yaml + "${helm_bin}" --kube-context="${KUBECONTEXT}" upgrade --install sam ./charts/sam-mesh \ + --namespace default \ + --set fullnameOverride="sam" \ + --set global.imageTag="local" \ + --set controlPlane.oidcIssuer="${ISSUERS//,/\\,}" \ + --set controlPlane.allowedAudiences="sam-mesh-audience\,sam-hub-audience" \ + --set controlPlane.insecureSkipTlsVerify=true \ + --set controlPlane.replicaCount=2 \ + --set controlPlane.hostPort=8080 \ + --set router.useOidcToken=false \ + --set router.hostPort=4501 + + kubectl --context="${KUBECONTEXT}" rollout status statefulset/sam-db --timeout=60s + kubectl --context="${KUBECONTEXT}" rollout status deployment/sam-control-plane --timeout=60s + kubectl --context="${KUBECONTEXT}" wait --for=condition=complete --timeout=60s job/sam-bootstrap kubectl --context="${KUBECONTEXT}" rollout status statefulset/sam-router --timeout=60s local i diff --git a/tests/integration/controlplane_pubsub_integration_test.go b/tests/integration/controlplane_pubsub_integration_test.go new file mode 100644 index 00000000..b69b1408 --- /dev/null +++ b/tests/integration/controlplane_pubsub_integration_test.go @@ -0,0 +1,237 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package integration_test + +import ( + "bytes" + "context" + "crypto/ed25519" + "net/http" + "path/filepath" + "testing" + "time" + + "github.com/google/sam/api" + "github.com/google/sam/internal/controlplane" + "github.com/google/sam/internal/storage" + "github.com/libp2p/go-libp2p" + pubsub "github.com/libp2p/go-libp2p-pubsub" + "github.com/libp2p/go-libp2p/core/peer" + "google.golang.org/protobuf/proto" +) + +func TestControlPlanePubSubEventIntegration(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "cp.db") + store, err := storage.NewSQLStore("sqlite", dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer func() { _ = store.Close() }() + + // 1. Create Control Plane Server + opts := controlplane.Options{ + ListenAddr: "127.0.0.1:0", + AdminToken: "test-admin-token", + } + cpServer, err := controlplane.NewServer(opts, store) + if err != nil { + t.Fatalf("failed to create control plane server: %v", err) + } + + if err := cpServer.Start(); err != nil { + t.Fatalf("failed to start control plane server: %v", err) + } + defer func() { _ = cpServer.Close() }() + + // Retrieve initial CP public key + _, pubKey, err := store.GetCurrentKey(ctx) + if err != nil { + t.Fatalf("failed to retrieve CP public key: %v", err) + } + + // 2. Set up P2PMeshAdapter for Control Plane + cpHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatalf("failed to create cp libp2p host: %v", err) + } + defer func() { _ = cpHost.Close() }() + + cpPS, err := pubsub.NewGossipSub(ctx, cpHost) + if err != nil { + t.Fatalf("failed to create cp pubsub: %v", err) + } + + meshAdapter, err := controlplane.NewP2PMeshAdapter(cpHost, cpPS, store) + if err != nil { + t.Fatalf("failed to create P2PMeshAdapter: %v", err) + } + defer func() { _ = meshAdapter.Close() }() + + cpServer.SetMeshAdapter(meshAdapter) + + // 3. Create a subscriber node listening to api.GossipEvents + subHost, err := libp2p.New(libp2p.ListenAddrStrings("/ip4/127.0.0.1/tcp/0")) + if err != nil { + t.Fatalf("failed to create subscriber host: %v", err) + } + defer func() { _ = subHost.Close() }() + + subPS, err := pubsub.NewGossipSub(ctx, subHost) + if err != nil { + t.Fatalf("failed to create subscriber pubsub: %v", err) + } + + topic, err := subPS.Join(api.GossipEvents) + if err != nil { + t.Fatalf("failed to join topic: %v", err) + } + sub, err := topic.Subscribe() + if err != nil { + t.Fatalf("failed to subscribe to topic: %v", err) + } + + // Connect subscriber node to Control Plane host + subHost.Peerstore().AddAddrs(cpHost.ID(), cpHost.Addrs(), 10*time.Second) + if err := subHost.Connect(ctx, peer.AddrInfo{ID: cpHost.ID(), Addrs: cpHost.Addrs()}); err != nil { + t.Fatalf("failed to connect subscriber node to cpHost: %v", err) + } + + // Allow mesh topology to settle + time.Sleep(500 * time.Millisecond) + + // 4. Trigger POST /policies on Control Plane + policyReq := &api.PolicyConfigUpdateRequest{ + Roles: []*api.PolicyRole{ + { + Name: "developer", + AllowedServices: []string{"mcp://api"}, + AllowedTargets: []string{"tcp://db:5432"}, + }, + }, + Bindings: []*api.PolicyBinding{ + { + Role: "developer", + Members: []string{"user:dev1"}, + }, + }, + } + policyReqData, err := proto.Marshal(policyReq) + if err != nil { + t.Fatalf("failed to marshal policy request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+cpServer.Addr()+"/policies", bytes.NewReader(policyReqData)) + if err != nil { + t.Fatalf("failed to create policy request: %v", err) + } + req.Header.Set("Authorization", "Bearer test-admin-token") + req.Header.Set("Content-Type", "application/x-protobuf") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to send policy request: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected HTTP 200 OK from /policies, got %d", resp.StatusCode) + } + + // Verify subscriber receives POLICY_UPDATE MeshEvent + msg, err := sub.Next(ctx) + if err != nil { + t.Fatalf("failed to receive POLICY_UPDATE event: %v", err) + } + + var event api.MeshEvent + if err := proto.Unmarshal(msg.Data, &event); err != nil { + t.Fatalf("failed to unmarshal MeshEvent: %v", err) + } + if event.Type != api.MeshEvent_POLICY_UPDATE { + t.Fatalf("expected event type POLICY_UPDATE, got %v", event.Type) + } + + // Verify signature + sig := event.Signature + event.Signature = nil + signedBytes, _ := proto.Marshal(&event) + if !ed25519.Verify(pubKey, signedBytes, sig) { + t.Fatalf("signature verification failed for POLICY_UPDATE event") + } + + // 5. Enroll a dummy node in storage and trigger POST /admin/revoke (BANNED event) + targetPeerID := "12D3KooWTestPeerToBan12345678901234567890" + nodeRecord := &storage.EnrolledNode{ + PeerID: targetPeerID, + PublicKey: []byte("dummy-key"), + Biscuit: []byte("dummy-token"), + Role: "node", + EnrollmentType: "BOOTSTRAP", + EnrolledAt: time.Now(), + } + if err := store.EnrollNode(ctx, nodeRecord); err != nil { + t.Fatalf("failed to enroll dummy node: %v", err) + } + + revokeReq := &api.TokenRevokeRequest{PeerId: targetPeerID} + revokeReqData, err := proto.Marshal(revokeReq) + if err != nil { + t.Fatalf("failed to marshal revoke request: %v", err) + } + + reqBan, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+cpServer.Addr()+"/admin/revoke", bytes.NewReader(revokeReqData)) + if err != nil { + t.Fatalf("failed to create revoke request: %v", err) + } + reqBan.Header.Set("Authorization", "Bearer test-admin-token") + reqBan.Header.Set("Content-Type", "application/x-protobuf") + + respBan, err := http.DefaultClient.Do(reqBan) + if err != nil { + t.Fatalf("failed to send revoke request: %v", err) + } + _ = respBan.Body.Close() + if respBan.StatusCode != http.StatusOK { + t.Fatalf("expected HTTP 200 OK from /admin/revoke, got %d", respBan.StatusCode) + } + + // Verify subscriber receives BANNED MeshEvent + msgBan, err := sub.Next(ctx) + if err != nil { + t.Fatalf("failed to receive BANNED event: %v", err) + } + + var eventBan api.MeshEvent + if err := proto.Unmarshal(msgBan.Data, &eventBan); err != nil { + t.Fatalf("failed to unmarshal BANNED MeshEvent: %v", err) + } + if eventBan.Type != api.MeshEvent_BANNED { + t.Fatalf("expected event type BANNED, got %v", eventBan.Type) + } + if eventBan.PeerId != targetPeerID { + t.Fatalf("expected peer ID %q, got %q", targetPeerID, eventBan.PeerId) + } + + // Verify signature + sigBan := eventBan.Signature + eventBan.Signature = nil + signedBanBytes, _ := proto.Marshal(&eventBan) + if !ed25519.Verify(pubKey, signedBanBytes, sigBan) { + t.Fatalf("signature verification failed for BANNED event") + } +} diff --git a/tests/integration/failover_test.go b/tests/integration/failover_test.go index 104d36d7..89b539be 100644 --- a/tests/integration/failover_test.go +++ b/tests/integration/failover_test.go @@ -63,10 +63,12 @@ roles: {} routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) nodeJWT := mintToken(map[string]interface{}{ - "sub": "mock-user", + "sub": "mock-user", + "roles": []string{api.RoleNode}, }) jwtPath := filepath.Join(tmpDir, "jwt.txt") @@ -102,7 +104,7 @@ roles: {} // 1. Start Control Plane A temporarily to generate keyring cmdCP_A_temp := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", httpPortCP_A), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnA, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -111,6 +113,7 @@ roles: {} t.Fatalf("failed to start temporary CP A: %v", err) } waitForControlPlane(t, httpPortCP_A) + injectPolicyYAML(t, httpPortCP_A, "test-admin-token", policyFile) _ = cmdCP_A_temp.Process.Signal(os.Interrupt) _ = cmdCP_A_temp.Wait() @@ -126,7 +129,7 @@ roles: {} // 3. Start CP A persistently cmdCP_A := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", httpPortCP_A), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnA, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -137,6 +140,7 @@ roles: {} defer func() { _ = cmdCP_A.Process.Kill(); _ = cmdCP_A.Wait() }() waitForControlPlane(t, httpPortCP_A) + injectPolicyYAML(t, httpPortCP_A, "test-admin-token", policyFile) // 4. Start Router A cmdRouterA := exec.Command(routerBin, @@ -157,7 +161,7 @@ roles: {} // 5. Start CP B cmdCP_B := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", httpPortCP_B), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnB, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -168,6 +172,7 @@ roles: {} defer func() { _ = cmdCP_B.Process.Kill(); _ = cmdCP_B.Wait() }() waitForControlPlane(t, httpPortCP_B) + injectPolicyYAML(t, httpPortCP_B, "test-admin-token", policyFile) // Start Router B cmdRouterB := exec.Command(routerBin, diff --git a/tests/integration/federation_test.go b/tests/integration/federation_test.go index 922b8a3e..a0bd2788 100644 --- a/tests/integration/federation_test.go +++ b/tests/integration/federation_test.go @@ -29,6 +29,7 @@ import ( "testing" "time" + "github.com/google/sam/api" "github.com/libp2p/go-libp2p/core/crypto" "github.com/libp2p/go-libp2p/core/peer" ) @@ -52,6 +53,7 @@ roles: allowed_services: - "mcp://*" - "system://sam.catalog" + allowed_targets: ["*"] ` writePolicyWithRouter(t, policyFile, policyContent) @@ -109,13 +111,14 @@ roles: routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) // Start Control Plane. We use journal_mode(DELETE) and busy_timeout(5000) // to ensure concurrent node enrollment writes do not trigger SQLITE_BUSY locking failures. cmdCP := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", cpPort), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", filepath.Join(tmpDir, "cp-keys.db")+"?_pragma=journal_mode(DELETE)&_pragma=busy_timeout(5000)", "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -128,6 +131,7 @@ roles: defer func() { _ = cmdCP.Process.Kill(); _ = cmdCP.Wait() }() waitForControlPlane(t, cpPort) + injectPolicyYAML(t, cpPort, "test-admin-token", policyFile) // Start Router A cmdRouterA := exec.Command(routerBin, @@ -185,7 +189,8 @@ roles: time.Sleep(2 * time.Second) nodeJWT := mintToken(map[string]interface{}{ - "sub": "mock-user", + "sub": "mock-user", + "roles": []string{api.RoleNode}, }) // Node A connects to Router A (via shared CP) diff --git a/tests/integration/local_policy_test.go b/tests/integration/local_policy_test.go index 4b1649f9..db4e9b0d 100644 --- a/tests/integration/local_policy_test.go +++ b/tests/integration/local_policy_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/google/sam/api" ) func TestLocalPolicyCanGrantPermissions(t *testing.T) { @@ -21,7 +23,7 @@ func TestLocalPolicyCanGrantPermissions(t *testing.T) { roles: none: allowed_services: [] - allowed_targets: [] + allowed_targets: ["*"] bindings: - role: none members: ["user:unprivileged-user"] @@ -60,7 +62,8 @@ attenuation: "--bind-addr", fmt.Sprintf("127.0.0.1:%d", apiPortB), "--api-token", apiTokenB, "--jwt", mintToken(map[string]interface{}{ - "sub": "nodeB-user", + "sub": "nodeB-user", + "roles": []string{api.RoleNode}, }), "--listen", "/ip4/127.0.0.1/tcp/0", "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", @@ -103,7 +106,8 @@ attenuation: "--bind-addr", fmt.Sprintf("127.0.0.1:%d", apiPortA), "--api-token", apiTokenA, "--jwt", mintToken(map[string]interface{}{ - "sub": "unprivileged-user", + "sub": "unprivileged-user", + "roles": []string{api.RoleNode}, }), "--listen", "/ip4/127.0.0.1/tcp/0", "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", @@ -203,7 +207,8 @@ attenuation: "--bind-addr", fmt.Sprintf("127.0.0.1:%d", apiPortB), "--api-token", apiTokenB, "--jwt", mintToken(map[string]interface{}{ - "sub": "nodeB-user", + "sub": "nodeB-user", + "roles": []string{api.RoleNode}, }), "--listen", "/ip4/127.0.0.1/tcp/0", "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", @@ -246,7 +251,8 @@ attenuation: "--bind-addr", fmt.Sprintf("127.0.0.1:%d", apiPortA), "--api-token", apiTokenA, "--jwt", mintToken(map[string]interface{}{ - "sub": "client-user", + "sub": "client-user", + "roles": []string{api.RoleNode}, }), "--listen", "/ip4/127.0.0.1/tcp/0", "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", diff --git a/tests/integration/mcp_local_tools_test.go b/tests/integration/mcp_local_tools_test.go index 6ddaa498..0c3ffaed 100644 --- a/tests/integration/mcp_local_tools_test.go +++ b/tests/integration/mcp_local_tools_test.go @@ -21,6 +21,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/google/sam/api" ) func TestMCPLocalTools(t *testing.T) { @@ -51,7 +53,8 @@ roles: {} homeB := t.TempDir() nodeJWT := mintToken(map[string]interface{}{ - "sub": "mock-user", + "sub": "mock-user", + "roles": []string{api.RoleNode}, }) // Start Node A diff --git a/tests/integration/minimal_helpers_test.go b/tests/integration/minimal_helpers_test.go index d154e463..e1efcb53 100644 --- a/tests/integration/minimal_helpers_test.go +++ b/tests/integration/minimal_helpers_test.go @@ -27,6 +27,8 @@ import ( "testing" "time" + "gopkg.in/yaml.v2" + "crypto/ed25519" "crypto/rand" "io" @@ -464,10 +466,10 @@ func startControlPlaneAndRouter(t *testing.T, tmpDir string, oidcURL string, min // 1. Start Control Plane cpCmd := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", cpPort), - "--policy-file", policyFile, "--db-dsn", filepath.Join(tmpDir, "cp-keys.db"), "--issuer", oidcURL, "--insecure-skip-tls-verify", + "--admin-token", "test-admin-token", ) cpCmd.Stdout = os.Stdout cpCmd.Stderr = os.Stderr @@ -478,10 +480,14 @@ func startControlPlaneAndRouter(t *testing.T, tmpDir string, oidcURL string, min // Wait for CP to be up waitForControlPlane(t, cpPort) + // Inject policy into CP database via API + injectPolicyYAML(t, cpPort, "test-admin-token", policyFile) + // 2. Start Router routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) routerCmd := exec.Command(routerBin, @@ -620,7 +626,7 @@ func writePolicyWithRouter(t *testing.T, path string, yamlContent string) { routerRole := fmt.Sprintf(` %s: allowed_services: [] - allowed_targets: []`, api.RoleRouter) + allowed_targets: ["*"]`, api.RoleRouter) routerBinding := fmt.Sprintf(` - role: %s members: ["group:routers"]`, api.RoleRouter) @@ -658,3 +664,72 @@ func waitForNodeOnline(t *testing.T, logPath string) { } } } + +type testBinding struct { + Role string `yaml:"role"` + Members []string `yaml:"members"` +} + +type testRolePolicy struct { + AllowedTargets []string `yaml:"allowed_targets"` + AllowedServices []string `yaml:"allowed_services"` +} + +type testPolicyConfig struct { + Bindings []testBinding `yaml:"bindings"` + Roles map[string]testRolePolicy `yaml:"roles"` +} + +func injectPolicyYAML(t *testing.T, port int, adminToken string, policyFile string) { + t.Helper() + + data, err := os.ReadFile(policyFile) + if err != nil { + t.Fatalf("failed to read policy file %s: %v", policyFile, err) + } + + var config testPolicyConfig + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("failed to unmarshal policy file %s: %v", policyFile, err) + } + + reqData := &api.PolicyConfigUpdateRequest{ + Roles: make([]*api.PolicyRole, 0, len(config.Roles)), + Bindings: make([]*api.PolicyBinding, 0, len(config.Bindings)), + } + + for name, role := range config.Roles { + reqData.Roles = append(reqData.Roles, &api.PolicyRole{ + Name: name, + AllowedServices: role.AllowedServices, + AllowedTargets: role.AllowedTargets, + }) + } + for _, b := range config.Bindings { + reqData.Bindings = append(reqData.Bindings, &api.PolicyBinding{ + Role: b.Role, + Members: b.Members, + }) + } + + protoData, err := proto.Marshal(reqData) + if err != nil { + t.Fatalf("failed to marshal policy request: %v", err) + } + + req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/policies", port), bytes.NewReader(protoData)) + req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("Authorization", "Bearer "+adminToken) + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("failed to POST policy to control plane: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("unexpected POST /policies status: %s (body: %s)", resp.Status, string(body)) + } +} diff --git a/tests/integration/multimaster_test.go b/tests/integration/multimaster_test.go index 822803e0..254e7782 100644 --- a/tests/integration/multimaster_test.go +++ b/tests/integration/multimaster_test.go @@ -60,10 +60,12 @@ roles: {} routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) nodeJWT := mintToken(map[string]interface{}{ - "sub": "mock-user", + "sub": "mock-user", + "roles": []string{api.RoleNode}, }) jwtPath := filepath.Join(tmpDir, "jwt.txt") @@ -84,7 +86,7 @@ roles: {} // 1. Start Control Plane A temporarily to generate keyring cmdCP_A_temp := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", portA), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnA, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -96,6 +98,7 @@ roles: {} t.Fatalf("failed to start temporary CP A: %v", err) } waitForControlPlane(t, portA) + injectPolicyYAML(t, portA, "test-admin-token", policyFile) _ = cmdCP_A_temp.Process.Signal(os.Interrupt) _ = cmdCP_A_temp.Wait() t.Logf("Temp CP A Stderr:\n%s\nTemp CP A Stdout:\n%s", stderrCP_A_temp.String(), stdoutCP_A_temp.String()) @@ -112,7 +115,7 @@ roles: {} // 3. Start CP A persistently cmdCP_A := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", portA), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnA, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -126,6 +129,7 @@ roles: {} defer func() { _ = cmdCP_A.Process.Kill(); _ = cmdCP_A.Wait() }() waitForControlPlane(t, portA) + injectPolicyYAML(t, portA, "test-admin-token", policyFile) // 4. Start Router A (registered with CP A) cmdRouterA := exec.Command(routerBin, @@ -149,7 +153,7 @@ roles: {} // 5. Start Control Plane B (sharing CP A's keys) cmdCP_B := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", portB), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", dsnB, "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -163,6 +167,7 @@ roles: {} defer func() { _ = cmdCP_B.Process.Kill(); _ = cmdCP_B.Wait() }() waitForControlPlane(t, portB) + injectPolicyYAML(t, portB, "test-admin-token", policyFile) // 5. Start Router B (registered with CP B) cmdRouterB := exec.Command(routerBin, diff --git a/tests/integration/policy_permutations_test.go b/tests/integration/policy_permutations_test.go index d18a816a..571997a3 100644 --- a/tests/integration/policy_permutations_test.go +++ b/tests/integration/policy_permutations_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + "github.com/google/sam/api" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -153,6 +154,7 @@ services: "--api-token", apiTokenB, "--jwt", mintToken(map[string]interface{}{ "sub": "bob-subject", + "roles": []string{api.RoleNode}, "email": "nodeB@example.com", "groups": []string{"compute", "backend"}, }), @@ -232,6 +234,13 @@ services: apiTokenA := "tokenA" apiPortA := getFreePort(t) + if r, ok := tt.jwtClaims["roles"]; ok { + if stringSlice, ok := r.([]string); ok { + tt.jwtClaims["roles"] = append(stringSlice, api.RoleNode) + } + } else { + tt.jwtClaims["roles"] = []string{api.RoleNode} + } jwtA := mintToken(tt.jwtClaims) cmdA := exec.Command(nodeBin, "run", diff --git a/tests/integration/revocation_test.go b/tests/integration/revocation_test.go index 628b3a58..3277f583 100644 --- a/tests/integration/revocation_test.go +++ b/tests/integration/revocation_test.go @@ -65,7 +65,7 @@ roles: // 2. Start Control Plane (PostgreSQL is used in GKE/Kind, but SQLite is completely fine here for in-process integration test speed) cpCmd := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", cpPort), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", filepath.Join(tmpDir, "cp-keys.db"), "--issuer", oidcURL, "--insecure-skip-tls-verify", @@ -81,11 +81,13 @@ roles: }() waitForControlPlane(t, cpPort) + injectPolicyYAML(t, cpPort, "test-admin-token", policyFile) // 3. Start Router routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) routerCmd := exec.Command(routerBin, @@ -123,7 +125,7 @@ roles: node1Cmd := exec.Command(nodeBin, "run", "--hub", fmt.Sprintf("http://127.0.0.1:%d", cpPort), - "--jwt", mintToken(map[string]interface{}{"sub": "mock-user"}), + "--jwt", mintToken(map[string]interface{}{"sub": "mock-user", "roles": []string{api.RoleNode}}), "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", "--listen", "/ip4/127.0.0.1/tcp/0", "--allow-loopback", @@ -153,7 +155,7 @@ roles: node2Cmd := exec.Command(nodeBin, "run", "--hub", fmt.Sprintf("http://127.0.0.1:%d", cpPort), - "--jwt", mintToken(map[string]interface{}{"sub": "mock-user"}), + "--jwt", mintToken(map[string]interface{}{"sub": "mock-user", "roles": []string{api.RoleNode}}), "--listen", "/ip4/127.0.0.1/udp/0/quic-v1", "--listen", "/ip4/127.0.0.1/tcp/0", "--allow-loopback", diff --git a/tests/integration/rotation_test.go b/tests/integration/rotation_test.go index 0147ec64..6c667b64 100644 --- a/tests/integration/rotation_test.go +++ b/tests/integration/rotation_test.go @@ -60,7 +60,7 @@ roles: // 2. Start Control Plane with aggressive key rotation cpCmd := exec.Command(cpBin, "--bind-address", fmt.Sprintf("127.0.0.1:%d", cpPort), - "--policy-file", policyFile, + "--admin-token", "test-admin-token", "--db-dsn", filepath.Join(tmpDir, "cp-keys.db"), "--issuer", oidcURL, "--key-rotation-interval", "4s", @@ -78,11 +78,13 @@ roles: }() waitForControlPlane(t, cpPort) + injectPolicyYAML(t, cpPort, "test-admin-token", policyFile) // 3. Start Router with aggressive key sync interval routerJWT := mintToken(map[string]interface{}{ "sub": "router-integration-1", "groups": []string{"routers"}, + "roles": []string{api.RoleRouter}, }) routerCmd := exec.Command(routerBin, @@ -108,7 +110,8 @@ roles: // 4. Start Node nodeJWT := mintToken(map[string]interface{}{ - "sub": "mock-user", + "sub": "mock-user", + "roles": []string{api.RoleNode}, }) nodeApiPort := getFreePort(t) nodeHome := t.TempDir() diff --git a/tests/integration/service_discovery_test.go b/tests/integration/service_discovery_test.go index 5cc1cbb1..f01450dd 100644 --- a/tests/integration/service_discovery_test.go +++ b/tests/integration/service_discovery_test.go @@ -18,7 +18,6 @@ import ( "bufio" "bytes" "encoding/json" - "google.golang.org/protobuf/encoding/protojson" "io" "net/http" "net/http/httptest" @@ -27,6 +26,8 @@ import ( "testing" "time" + "google.golang.org/protobuf/encoding/protojson" + "github.com/google/sam/api" "github.com/libp2p/go-libp2p/core/peer" )