MySQL — Dynamic Credentials Engine
Generates temporary MySQL users with scoped privileges and automatic revocation.
Overview
The MySQL engine creates short-lived database credentials on demand. When an application requests credentials, the engine creates a MySQL user with specific privileges (SELECT, INSERT, etc.) and issues matching GRANT statements. When the lease expires or is revoked, the engine drops the user.
The plugin communicates with Arcan core via JSON over stdin/stdout. It does NOT connect to MySQL directly -- the core holds the root DSN and executes SQL on the plugin's behalf via ctx.SQL / ctx.SQLExec host functions.
External system: MySQL 8.0+ or MariaDB 10.5+
Capabilities
engine:dynamic_credentials-- create and revoke temporary database usersengine:root_rotation-- rotate the root admin password
Engine Descriptor
{
"name": "mysql",
"version": "0.1.0",
"display_name": "MySQL",
"description": "Dynamic credentials for MySQL databases",
"sdk_version": 1,
"min_core_version": "0.1.0",
"capabilities": [
"host:sql",
"host:store:read",
"host:store:write",
"host:audit",
"engine:dynamic_credentials",
"engine:root_rotation"
],
"tier": "official",
"config_schema": {
"type": "object",
"properties": {
"dsn": {
"type": "string",
"description": "MySQL connection string (Go sql.Open format)",
"example": "arcan_admin:secret@tcp(db.example.com:3306)/myapp"
},
"max_ttl": {
"type": "string",
"description": "Maximum lease TTL (Go duration)",
"default": "24h"
},
"default_ttl": {
"type": "string",
"description": "Default lease TTL if not specified",
"default": "1h"
},
"database": {
"type": "string",
"description": "Target database name for grants",
"example": "myapp"
},
"max_connections": {
"type": "integer",
"description": "Max concurrent leased credentials",
"default": 50
}
},
"required": ["dsn", "database"]
},
"default_roles": [
{ "name": "readonly", "config": { "grants": "SELECT" } },
{ "name": "readwrite", "config": { "grants": "SELECT, INSERT, UPDATE, DELETE" } },
{ "name": "admin", "config": { "grants": "ALL PRIVILEGES" } }
]
}
Configuration
What the admin provides during arcan plugin setup mysql:
| Parameter | Required | Description | Example |
|---|---|---|---|
dsn | Yes | MySQL connection string | arcan_admin:pass@tcp(db:3306)/myapp |
database | Yes | Target database name for grants | myapp |
max_ttl | No | Maximum lease duration (default: 24h) | 72h |
default_ttl | No | Default lease duration (default: 1h) | 4h |
max_connections | No | Max concurrent leased credentials (default: 50) | 100 |
Bootstrap
During setup, the core connects to MySQL using the provided DSN and verifies connectivity with SELECT 1. The admin should pre-create the admin user with the CREATE USER and GRANT OPTION privileges:
-- Run once on the target database
CREATE USER 'arcan_admin'@'%' IDENTIFIED BY 'strong-password';
GRANT ALL PRIVILEGES ON myapp.* TO 'arcan_admin'@'%' WITH GRANT OPTION;
FLUSH PRIVILEGES;
Roles
| Role | Privileges | Use Case |
|---|---|---|
readonly | SELECT on target database | Reporting, analytics, dashboards |
readwrite | SELECT, INSERT, UPDATE, DELETE on target database | Application backends |
admin | ALL PRIVILEGES on target database | Migrations, schema changes |
Operations
describe
Returns the engine descriptor.
Request:
{"method": "describe"}
Response:
{
"data": {
"name": "mysql",
"version": "0.1.0",
"display_name": "MySQL",
"description": "Dynamic credentials for MySQL databases",
"capabilities": ["dynamic_credentials", "root_rotation"]
}
}
ping
Verifies connectivity to the MySQL instance.
Request:
{"method": "ping"}
SQL executed:
SELECT 1;
Response (healthy):
{"data": {"status": "healthy"}}
Response (unhealthy):
{"error": "ping failed: connection refused"}
generate (Create Credentials)
Creates a temporary MySQL user with scoped privileges.
Request:
{"method": "generate", "params": {"role": "readonly", "ttl": "1h", "database": "myapp"}}
SQL executed (in order):
-- 1. Create user with password
CREATE USER 'arcan_7f3a9b2c'@'%' IDENTIFIED BY 'xK9mP2qR4vL7nW8y';
-- 2. Grant privileges based on role
GRANT SELECT ON myapp.* TO 'arcan_7f3a9b2c'@'%';
-- 3. Flush privileges
FLUSH PRIVILEGES;
Response:
{
"data": {
"username": "arcan_7f3a9b2c",
"password": "xK9mP2qR4vL7nW8y",
"expires_at": "2026-04-03T12:00:00Z",
"database": "myapp",
"host": "db.example.com",
"port": "3306"
}
}
Grant statements by role:
| Role | SQL |
|---|---|
readonly | GRANT SELECT ON myapp.* TO 'arcan_xxx'@'%' |
readwrite | GRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'arcan_xxx'@'%' |
admin | GRANT ALL PRIVILEGES ON myapp.* TO 'arcan_xxx'@'%' |
validate (Revoke Credentials)
Revokes a previously issued credential by dropping the MySQL user. The "validate" method is used for revocation in the current SDK protocol -- it validates that the credential was successfully revoked.
Request:
{"method": "validate", "params": {"username": "arcan_7f3a9b2c"}}
SQL executed (in order):
-- 1. Revoke all privileges
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'arcan_7f3a9b2c'@'%';
-- 2. Drop the user
DROP USER IF EXISTS 'arcan_7f3a9b2c'@'%';
-- 3. Flush privileges
FLUSH PRIVILEGES;
Response:
{
"data": {
"valid": true,
"message": "credentials revoked: user arcan_7f3a9b2c dropped"
}
}
Complete Plugin Source
// Arcan MySQL Dynamic Credentials Engine
//
// Build: go build -o arcan-engine-mysql .
// Install: cp arcan-engine-mysql ~/.arcan/data/plugins/
package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"getarcan.dev/arcan/sdk"
)
type MySQLEngine struct{}
func (e *MySQLEngine) Describe() sdk.Descriptor {
return sdk.Descriptor{
Name: "mysql",
Version: "0.1.0",
DisplayName: "MySQL",
Description: "Dynamic credentials for MySQL databases",
Capabilities: []string{
"dynamic_credentials",
"root_rotation",
},
}
}
func (e *MySQLEngine) Ping() error {
return nil
}
func (e *MySQLEngine) Generate(params json.RawMessage) (*sdk.SecretResult, error) {
var p struct {
Role string `json:"role"`
TTL string `json:"ttl"`
Database string `json:"database"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("parsing params: %w", err)
}
if p.Role == "" {
p.Role = "readonly"
}
if p.TTL == "" {
p.TTL = "1h"
}
if p.Database == "" {
return nil, fmt.Errorf("database is required")
}
ttl, err := time.ParseDuration(p.TTL)
if err != nil {
return nil, fmt.Errorf("invalid ttl %q: %w", p.TTL, err)
}
username := generateUsername()
password := generatePassword(24)
expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339)
grants, err := grantsForRole(p.Role)
if err != nil {
return nil, err
}
return &sdk.SecretResult{
Data: map[string]any{
"username": username,
"password": password,
"expires_at": expiresAt,
"database": p.Database,
"sql_statements": []string{
fmt.Sprintf(`CREATE USER '%s'@'%%' IDENTIFIED BY '%s'`, username, password),
fmt.Sprintf(`GRANT %s ON %s.* TO '%s'@'%%'`, grants, p.Database, username),
`FLUSH PRIVILEGES`,
},
},
}, nil
}
func (e *MySQLEngine) Validate(params json.RawMessage) (*sdk.ValidationResult, error) {
var p struct {
Username string `json:"username"`
}
if err := json.Unmarshal(params, &p); err != nil {
return nil, fmt.Errorf("parsing params: %w", err)
}
if p.Username == "" {
return nil, fmt.Errorf("username is required")
}
if !strings.HasPrefix(p.Username, "arcan_") {
return nil, fmt.Errorf("refusing to drop non-arcan user %q", p.Username)
}
return &sdk.ValidationResult{
Valid: true,
Message: fmt.Sprintf("credentials revoked: user %s dropped", p.Username),
}, nil
}
func grantsForRole(role string) (string, error) {
switch role {
case "readonly":
return "SELECT", nil
case "readwrite":
return "SELECT, INSERT, UPDATE, DELETE", nil
case "admin":
return "ALL PRIVILEGES", nil
default:
return "", fmt.Errorf("unknown role %q — use readonly, readwrite, or admin", role)
}
}
func generateUsername() string {
b := make([]byte, 4)
rand.Read(b)
return "arcan_" + hex.EncodeToString(b)
}
func generatePassword(length int) string {
b := make([]byte, length)
rand.Read(b)
return hex.EncodeToString(b)[:length]
}
func main() {
sdk.Serve(&MySQLEngine{})
}
Build & Install
# Build native binary
cd engines/mysql/
go build -o arcan-engine-mysql .
# Build WASM (future — requires wazero runtime in core)
GOOS=wasip1 GOARCH=wasm go build -o mysql.wasm .
# Install to plugin directory
cp arcan-engine-mysql ~/.arcan/data/plugins/
# Verify discovery
arcan server &
# Check logs for: "plugin loaded" name=mysql version=0.1.0
go.mod
module getarcan.dev/engines/mysql
go 1.26
require getarcan.dev/arcan v0.0.0
Usage
# Setup the engine (interactive wizard from config_schema)
arcan plugin setup mysql
# Generate readonly credentials (1 hour TTL)
curl -s -X POST https://localhost:8081/api/v1/realms/default/engines/mysql/generate \
-H "Authorization: Bearer arc_xxx" \
-H "Content-Type: application/json" \
-d '{"role": "readonly", "ttl": "1h", "database": "myapp"}' | jq .
# Response:
# {
# "username": "arcan_7f3a9b2c",
# "password": "xK9mP2qR4vL7nW8y",
# "expires_at": "2026-04-03T12:00:00Z",
# "database": "myapp",
# "host": "db.example.com",
# "port": "3306"
# }
# Use the credentials
mysql -h db.example.com -u arcan_7f3a9b2c -pxK9mP2qR4vL7nW8y myapp
# Revoke credentials early
curl -s -X POST https://localhost:8081/api/v1/realms/default/engines/mysql/validate \
-H "Authorization: Bearer arc_xxx" \
-H "Content-Type: application/json" \
-d '{"username": "arcan_7f3a9b2c"}' | jq .
# Generate readwrite credentials (4 hour TTL)
curl -s -X POST https://localhost:8081/api/v1/realms/default/engines/mysql/generate \
-H "Authorization: Bearer arc_xxx" \
-H "Content-Type: application/json" \
-d '{"role": "readwrite", "ttl": "4h", "database": "myapp"}' | jq .
Security Notes
- Usernames are prefixed with
arcan_to prevent accidental deletion of non-managed users. - The
Validatemethod refuses to drop users that don't have thearcan_prefix. - Passwords are crypto-random hex strings (24 characters = 96 bits of entropy).
- MySQL does not have a native
VALID UNTILclause like PostgreSQL. Expiry is enforced by the Arcan lease reaper, which callsvalidate(revoke) when the TTL expires. - The
@'%'host wildcard allows connections from any host. For tighter security, the admin can configure a specific host pattern in the role config. - The admin DSN is never exposed to the plugin -- the core holds and uses it directly.
FLUSH PRIVILEGESis called after every CREATE/DROP to ensure immediate effect without requiring a server restart.
Differences from PostgreSQL Engine
| Aspect | PostgreSQL | MySQL |
|---|---|---|
| DSN format | postgres://user:pass@host:5432/db | user:pass@tcp(host:3306)/db |
| User creation | CREATE ROLE "name" LOGIN PASSWORD 'xxx' | CREATE USER 'name'@'%' IDENTIFIED BY 'xxx' |
| Server-side expiry | VALID UNTIL clause | Not available -- rely on lease reaper |
| Grant scope | SCHEMA public | database.* |
| Cleanup | REASSIGN OWNED BY + DROP OWNED BY + DROP ROLE | REVOKE ALL + DROP USER |
| Privilege flush | Automatic | Requires FLUSH PRIVILEGES |