🔐 AccessOrbit: One-Click, Time-Limited DB Access with Hashicorp Vault & GitHub Actions

🔐 AccessOrbit: One-Click, Time-Limited DB Access with Hashicorp Vault &  GitHub Actions
Photo by Benjamin Bortels on Unsplash

At some point, most platform teams face a common challenge: how to provide engineers with timely, auditable, and secure access to production (or staging) databases—without blowing a hole in security or flooding Slack with manual approval chaos.

At Blueground, we've implemented a fully automated, self-serve process using:

  • 🧱 HashiCorp Vault to manage ephemeral database credentials
  • ⚙️ GitHub Actions as the request engine
  • 🧾 GitHub Issues for auditability and traceability
  • 💬 Slack for notifications and approvals

We call it AccessOrbit.

Let's walk through how it works. 👇


💡 The Problem

Before AccessOrbit, we relied on IAM-based authentication for engineers to access databases. While it offered fine-grained access control, it also came with tradeoffs:

  • IAM credentials could enable long-lived or even permanent access to databases
  • There was no built-in TTL, making it difficult to enforce time-bound access

In short, we had a process, but it wasn’t ephemeral, auditable, or secure by design. We needed something safer and more self-serve.


🛠️ Our Solution: AccessOrbit Workflow

Using a combination of GitHub Actions, HashiCorp Vault, and Slack bots, we built a workflow that enforces principle of least privilege, supports manual approvals, and issues credentials that auto-expire.

Here's how we can build the same:

  • Deploy a self-hosted version of Hashicorp Vault
  • Create a Database Secrets engine
  • Assign specific database connections & roles
  • Build Github Action for requesting a temporary credential
  • Build AccessOrbit Slack Bot to communicate temporary credentials back to engineers

Creating a Database Secrets Engine

resource "vault_database_secrets_mount" "database" {
  path                      = "database"
  default_lease_ttl_seconds = 14400 #4h
  max_lease_ttl_seconds     = 14400 #4h
}

Assigning specific Database Connections & Roles

resource "vault_database_secret_backend_connection" "a_database_schema_pair" {
  backend = vault_database_secrets_mount.database.path
  name    = "db-<database>-<schema>"
  allowed_roles = [
    "our-predefined-read-role",
    "our-predefined-write-role"
  ]

  postgresql {
    username       = data.aws_ssm_parameter.<db_username>.value
    password       = data.aws_ssm_parameter.<db_password>.value
    connection_url = "postgresql://{{username}}:{{password}}@${data.aws_ssm_parameter.<db_endpoint>.value}/<database>"
  }
}

We assume usage of public schema here, for the sake of simplicity!

resource "vault_database_secret_backend_role" "db_predefined_read_role" {
  name        = "db_predefined_read_role"
  backend     = vault_database_secrets_mount.database.path
  db_name     = "<our_database_name>"
  default_ttl = 14400
  max_ttl     = 14400
  creation_statements = [
    "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}' INHERIT;",
    "GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";",
    "GRANT USAGE ON SCHEMA public TO \"{{name}}\";",
    "GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO \"{{name}}\";",
    "GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";"
  ]

  revocation_statements = [
    "REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM \"{{name}}\";",
    "REVOKE USAGE ON SCHEMA public FROM \"{{name}}\";",
    "REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM \"{{name}}\";",
    "REVOKE USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public FROM \"{{name}}\";",
    "ALTER ROLE \"{{name}}\" NOLOGIN;",
    "DROP ROLE \"{{name}}\";"
  ]
}

Build GithubAction for requesting temporary credentials

name: 🔐 Database Access - Request

on:
  workflow_dispatch:
    inputs:
      database_instance:
        description: "Select database instance"
        required: true
        type: choice
        options:
          - db1
          - db2
      db_and_schema:
        description: "Database and schema name"
        required: true
        type: choice
        options:
          - db1-schema1
          - db1-schema2
          - db2-schema1
          - db2-schema2
      operation_mode:
        description: "Select operation mode"
        required: true
        type: choice
        options:
          - read
          - write
      email:
        description: "User email"
        required: true
        type: string
      reason:
        description: "Reason for the request"
        required: true
        type: string

concurrency:
  group: "${{ inputs.database_instance }}-${{inputs.db_and_schema}}-${{inputs.email}}"
  cancel-in-progress: true

jobs:
  access-request:
    timeout-minutes: 15
    runs-on: <your-runner-label>

    permissions:
      issues: write
      contents: write
      id-token: write

    env:
      VAULT_ADDR: <your-vault-address>

    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: <your-role-to-assume>
          aws-region: <your-aws-region>
      - name: Load DB Access SlackBot token from SSM
        ...
      - name: Lookup Slack user
        id: slack-user
        uses: danielbowden/action-slack-lookupByEmail@v1
        with:
          email: ${{ inputs.email }}
        env:
          SLACK_BOT_TOKEN: <your-slack-bot-token>
      - name: Load GITHUB_TOKEN from SSM
        ...
      - name: Manual Approval
        uses: pavlospt/manual-approval@v2
        with:
          secret: ${{ steps.load-github-token.outputs.value }}
          approvers: <your-approvers>
          minimum-approvals: 1
          issue-title: |
            <your issue payload>
      - name: Load DB_VAULT_ROLE_ID from SSM
        ...
      - name: Load DB_VAULT_SECRET_ID from SSM
        ...
      - name: Import Secrets
        id: import-secrets
        uses: hashicorp/vault-action@v2
        with:
          url: ${{ env.VAULT_ADDR }}
          exportToken: true
          method: approle
          roleId: ${{ steps.load-db-vault-role-id.outputs.value }}
          secretId: ${{ steps.load-db-vault-secret-id.outputs.value }}
          secrets: |
            database/creds/${{ inputs.database_instance }}-${{ inputs.db_and_schema }}-${{ inputs.operation_mode }} username ;
            database/creds/${{ inputs.database_instance }}-${{ inputs.db_and_schema }}-${{ inputs.operation_mode }} password
      - name: Write audit info to vault
        run: |
          <write audit info - optional>
      - name: Post Slack message for DB Access Request
        uses: slackapi/slack-github-action@v2.0.0
        env:
          SLACK_USER_ID: ${{ fromJSON(steps.slack-user.outputs.user).id }}
        with:
          method: chat.postEphemeral
          token: <your slack bot token>
          errors: true
          payload: |
            <your custom Slack payload to notify your users>
      - name: Job summary
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            <Add a summary describing the details of the request>

Build AccessOrbit Slack bot

This is pretty easy: just create a new Slack App that will act only as a bot and use the proper token on the GithubAction described above!

Below you can find a sequence diagram, describing the whole process from the Engineer creating the request, to AccessOrbit communicating the temporary credentials to the Engineer!


🔑 Key Highlights

  • Vault Dynamic Secrets: Configured roles issue time-bound (e.g., 1 hr TTL), schema-scoped database credentials with defined SQL privileges, minimizing blast radius.

  • GitHub Actions Frontend: Engineers kick off a form-driven workflow that validates inputs, creates a GitHub Issue, notifies Slack, and pauses for manual approval.

  • AccessOrbit Slack Bot: Orchestrates the flow in Slack—announcing requests and DM-ing credentials post-approval.

  • GitHub Issues Audit Trail: Logs every request and approval (who, what, when) with links to Vault logs for compliance.


🔄 Lifecycle & Expiry

No need to remember to revoke access. Credentials expire automatically based on Vault TTL settings (e.g., 1h by default). Engineers are advised to request credentials again, if needed beyond that window.


✅ Benefits

  • 🔐 Least privilege access: fine-grained schema-level roles
  • ⏱️ Time-bound credentials: automatic expiry via Vault
  • 📝 Auditable: GitHub Issues + Vault audit logs
  • 🔔 Real-time approval flow: Slack integration

If you're building something similar, or want to chat about secure access workflows, we'd love to hear from you.