Skip to main content

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 users
  • engine: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:

ParameterRequiredDescriptionExample
dsnYesMySQL connection stringarcan_admin:pass@tcp(db:3306)/myapp
databaseYesTarget database name for grantsmyapp
max_ttlNoMaximum lease duration (default: 24h)72h
default_ttlNoDefault lease duration (default: 1h)4h
max_connectionsNoMax 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

RolePrivilegesUse Case
readonlySELECT on target databaseReporting, analytics, dashboards
readwriteSELECT, INSERT, UPDATE, DELETE on target databaseApplication backends
adminALL PRIVILEGES on target databaseMigrations, 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:

RoleSQL
readonlyGRANT SELECT ON myapp.* TO 'arcan_xxx'@'%'
readwriteGRANT SELECT, INSERT, UPDATE, DELETE ON myapp.* TO 'arcan_xxx'@'%'
adminGRANT 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 Validate method refuses to drop users that don't have the arcan_ prefix.
  • Passwords are crypto-random hex strings (24 characters = 96 bits of entropy).
  • MySQL does not have a native VALID UNTIL clause like PostgreSQL. Expiry is enforced by the Arcan lease reaper, which calls validate (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 PRIVILEGES is called after every CREATE/DROP to ensure immediate effect without requiring a server restart.

Differences from PostgreSQL Engine

AspectPostgreSQLMySQL
DSN formatpostgres://user:pass@host:5432/dbuser:pass@tcp(host:3306)/db
User creationCREATE ROLE "name" LOGIN PASSWORD 'xxx'CREATE USER 'name'@'%' IDENTIFIED BY 'xxx'
Server-side expiryVALID UNTIL clauseNot available -- rely on lease reaper
Grant scopeSCHEMA publicdatabase.*
CleanupREASSIGN OWNED BY + DROP OWNED BY + DROP ROLEREVOKE ALL + DROP USER
Privilege flushAutomaticRequires FLUSH PRIVILEGES