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


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.