feat(05-03): implement lagoon.Encrypted, key:generate, Laravel decrypt helper
- AES-256-GCM with stdlib HKDF column keys and previous_keys fallback - Fail-loud LoadAppKey, redacting marshal paths, key:generate via crypto/rand - Standalone DecryptLaravelPayload unwired from Scan/Value
This commit is contained in:
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RuntimeCommands returns migrate, migrate:rollback and migrate:status.
|
||||
// RuntimeCommands returns migrate, migrate:rollback, migrate:status, and key:generate.
|
||||
// Serve is registered separately via surf.ServeCommand.
|
||||
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
|
||||
return []bonfire.Command{
|
||||
@@ -71,6 +71,7 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
||||
})
|
||||
},
|
||||
},
|
||||
KeyGenerateCommand(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,11 +80,21 @@ func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) {
|
||||
}
|
||||
|
||||
// OpenFromApp reads database.dsn from app config and opens the shared pool.
|
||||
// It also loads app.key via LoadAppKey and PublishEncryptionKeys so Encrypted
|
||||
// columns do not re-read config on every row.
|
||||
func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) {
|
||||
if app == nil || app.Config == nil {
|
||||
return nil, nil, fmt.Errorf("lagoon: app config is missing")
|
||||
}
|
||||
return Open(ctx, DSN(app.Config))
|
||||
sqlDB, gdb, err := Open(ctx, DSN(app.Config))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := loadEncryptionKeysFromApp(app); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
return sqlDB, gdb, nil
|
||||
}
|
||||
|
||||
// DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN).
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -139,6 +141,7 @@ func TestOpenFromAppReadsDSN(t *testing.T) {
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=development",
|
||||
"SUMMER_DATABASE__DSN=" + dsn,
|
||||
"SUMMER_APP__KEY=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("T"), 32)),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
349
lagoon/encrypted.go
Normal file
349
lagoon/encrypted.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hkdf"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql/driver"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.golem15.com/golem15/summercms/backpack"
|
||||
"git.golem15.com/golem15/summercms/compass"
|
||||
)
|
||||
|
||||
const (
|
||||
encryptedFormatV1 = byte(0x10)
|
||||
encryptedNonceSize = 12
|
||||
encryptedKeySize = 32
|
||||
columnKeyInfo = "summercms.lagoon.encrypted.v1"
|
||||
redactedLiteral = "[redacted]"
|
||||
appKeyErr = "lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)"
|
||||
)
|
||||
|
||||
// Encrypted is an AES-256-GCM at-rest cast. Plaintext is unexported; the only
|
||||
// greppable accessor is Reveal. MarshalJSON, String, and GoString always redact.
|
||||
type Encrypted struct {
|
||||
plaintext []byte
|
||||
set bool
|
||||
}
|
||||
|
||||
// NewEncrypted holds plaintext for a subsequent Value() write.
|
||||
func NewEncrypted(plaintext string) Encrypted {
|
||||
return Encrypted{plaintext: []byte(plaintext), set: true}
|
||||
}
|
||||
|
||||
// Reveal returns the plaintext, or "" if the value is unset.
|
||||
func (e Encrypted) Reveal() string {
|
||||
if !e.set {
|
||||
return ""
|
||||
}
|
||||
return string(e.plaintext)
|
||||
}
|
||||
|
||||
// MarshalJSON always emits a redaction, never plaintext.
|
||||
func (e Encrypted) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(redactedLiteral)
|
||||
}
|
||||
|
||||
// String returns a fixed redaction literal.
|
||||
func (e Encrypted) String() string { return redactedLiteral }
|
||||
|
||||
// GoString returns a fixed redaction so %#v cannot leak plaintext.
|
||||
func (e Encrypted) GoString() string { return "lagoon.Encrypted{[redacted]}" }
|
||||
|
||||
// Scan decrypts versioned ciphertext using the published primary key, then
|
||||
// each previous key. src may be string, []byte, or nil (SQL NULL).
|
||||
func (e *Encrypted) Scan(src any) error {
|
||||
if e == nil {
|
||||
return fmt.Errorf("lagoon: encrypted scan on nil receiver")
|
||||
}
|
||||
if src == nil {
|
||||
e.plaintext = nil
|
||||
e.set = false
|
||||
return nil
|
||||
}
|
||||
var raw string
|
||||
switch v := src.(type) {
|
||||
case string:
|
||||
raw = v
|
||||
case []byte:
|
||||
raw = string(v)
|
||||
default:
|
||||
return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src)
|
||||
}
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
e.plaintext = nil
|
||||
e.set = false
|
||||
return nil
|
||||
}
|
||||
plain, err := decryptCiphertext(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.plaintext = plain
|
||||
e.set = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value re-encrypts the current plaintext under the published primary key.
|
||||
// Unset values store SQL NULL.
|
||||
func (e Encrypted) Value() (driver.Value, error) {
|
||||
if !e.set {
|
||||
return nil, nil
|
||||
}
|
||||
return encryptPlaintext(e.plaintext)
|
||||
}
|
||||
|
||||
type encryptionKeys struct {
|
||||
primary []byte
|
||||
previous [][]byte
|
||||
primaryID byte
|
||||
}
|
||||
|
||||
var (
|
||||
keysMu sync.RWMutex
|
||||
currentKeys *encryptionKeys
|
||||
)
|
||||
|
||||
// PublishEncryptionKeys installs column-encryption keys for Encrypted Scan/Value.
|
||||
// OpenFromApp calls this once per boot so row-level encrypt/decrypt does not
|
||||
// re-read config. Pass a nil app from tests that only need the package-level
|
||||
// key set. The backpack publish is best-effort once-per-boot; package-level
|
||||
// keys are the live source of truth and may be rotated in tests.
|
||||
func PublishEncryptionKeys(app *backpack.App, primaryKey []byte, previousKeys [][]byte) error {
|
||||
k, err := newEncryptionKeys(primaryKey, previousKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
setCurrentKeys(k)
|
||||
if app == nil {
|
||||
return nil
|
||||
}
|
||||
if _, ok := app.Lookup[*encryptionKeys](); ok {
|
||||
return nil
|
||||
}
|
||||
return app.Publish(k)
|
||||
}
|
||||
|
||||
func newEncryptionKeys(primaryKey []byte, previousKeys [][]byte) (*encryptionKeys, error) {
|
||||
if len(primaryKey) != encryptedKeySize {
|
||||
return nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
prev := make([][]byte, 0, len(previousKeys))
|
||||
for i, k := range previousKeys {
|
||||
if len(k) != encryptedKeySize {
|
||||
return nil, fmt.Errorf("lagoon: app.previous_keys[%d] is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)", i)
|
||||
}
|
||||
prev = append(prev, cloneBytes(k))
|
||||
}
|
||||
id := byte(len(prev) + 1)
|
||||
if id > 0x0F {
|
||||
id = 0x0F
|
||||
}
|
||||
return &encryptionKeys{
|
||||
primary: cloneBytes(primaryKey),
|
||||
previous: prev,
|
||||
primaryID: id,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func setCurrentKeys(k *encryptionKeys) {
|
||||
keysMu.Lock()
|
||||
currentKeys = k
|
||||
keysMu.Unlock()
|
||||
}
|
||||
|
||||
func clearEncryptionKeys() {
|
||||
setCurrentKeys(nil)
|
||||
}
|
||||
|
||||
func liveKeys() (*encryptionKeys, error) {
|
||||
keysMu.RLock()
|
||||
k := currentKeys
|
||||
keysMu.RUnlock()
|
||||
if k == nil || len(k.primary) != encryptedKeySize {
|
||||
return nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// LoadAppKey reads app.key and app.previous_keys from cfg. Missing, short, or
|
||||
// undecodable values fail loudly with no default (D-11).
|
||||
func LoadAppKey(cfg *compass.Config) ([]byte, [][]byte, error) {
|
||||
if cfg == nil {
|
||||
return nil, nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
primary, err := decodeAppKey(cfg.String("app.key"))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var previous [][]byte
|
||||
if v, ok := cfg.Lookup("app.previous_keys"); ok && v != nil {
|
||||
previous, err = decodePreviousKeys(v)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return primary, previous, nil
|
||||
}
|
||||
|
||||
func decodeAppKey(s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil || len(raw) != encryptedKeySize {
|
||||
return nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func decodePreviousKeys(v any) ([][]byte, error) {
|
||||
items, ok := asStringList(v)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("lagoon: app.previous_keys is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)")
|
||||
}
|
||||
out := make([][]byte, 0, len(items))
|
||||
for _, item := range items {
|
||||
raw, err := decodeAppKey(item)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: app.previous_keys is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)")
|
||||
}
|
||||
out = append(out, raw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func asStringList(v any) ([]string, bool) {
|
||||
switch list := v.(type) {
|
||||
case []string:
|
||||
return list, true
|
||||
case []any:
|
||||
out := make([]string, 0, len(list))
|
||||
for _, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func deriveColumnKey(appKey []byte) ([]byte, error) {
|
||||
if len(appKey) != encryptedKeySize {
|
||||
return nil, fmt.Errorf(appKeyErr)
|
||||
}
|
||||
return hkdf.Key(sha256.New, appKey, nil, columnKeyInfo, encryptedKeySize)
|
||||
}
|
||||
|
||||
func encryptPlaintext(plain []byte) (string, error) {
|
||||
keys, err := liveKeys()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
colKey, err := deriveColumnKey(keys.primary)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(colKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lagoon: encrypted aes: %w", err)
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lagoon: encrypted gcm: %w", err)
|
||||
}
|
||||
nonce := make([]byte, encryptedNonceSize)
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", fmt.Errorf("lagoon: encrypted nonce: %w", err)
|
||||
}
|
||||
sealed := gcm.Seal(nil, nonce, plain, nil)
|
||||
out := make([]byte, 1+len(nonce)+len(sealed))
|
||||
out[0] = encryptedFormatV1 | (keys.primaryID & 0x0F)
|
||||
copy(out[1:], nonce)
|
||||
copy(out[1+len(nonce):], sealed)
|
||||
return base64.StdEncoding.EncodeToString(out), nil
|
||||
}
|
||||
|
||||
func decryptCiphertext(stored string) ([]byte, error) {
|
||||
keys, err := liveKeys()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(stored)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: encrypted ciphertext is not base64")
|
||||
}
|
||||
if len(raw) < 1+encryptedNonceSize+16 {
|
||||
return nil, fmt.Errorf("lagoon: encrypted ciphertext is truncated")
|
||||
}
|
||||
if raw[0]&0xF0 != encryptedFormatV1 {
|
||||
return nil, fmt.Errorf("lagoon: encrypted ciphertext has unknown format")
|
||||
}
|
||||
nonce := raw[1 : 1+encryptedNonceSize]
|
||||
sealed := raw[1+encryptedNonceSize:]
|
||||
|
||||
candidates := make([][]byte, 0, 1+len(keys.previous))
|
||||
candidates = append(candidates, keys.primary)
|
||||
candidates = append(candidates, keys.previous...)
|
||||
var last error
|
||||
for _, appKey := range candidates {
|
||||
plain, err := gcmOpen(appKey, nonce, sealed)
|
||||
if err == nil {
|
||||
return plain, nil
|
||||
}
|
||||
last = err
|
||||
}
|
||||
if last == nil {
|
||||
last = fmt.Errorf("lagoon: encrypted decrypt failed")
|
||||
}
|
||||
return nil, last
|
||||
}
|
||||
|
||||
func gcmOpen(appKey, nonce, sealed []byte) ([]byte, error) {
|
||||
colKey, err := deriveColumnKey(appKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(colKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return gcm.Open(nil, nonce, sealed, nil)
|
||||
}
|
||||
|
||||
func cloneBytes(b []byte) []byte {
|
||||
if b == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, len(b))
|
||||
copy(out, b)
|
||||
return out
|
||||
}
|
||||
|
||||
func loadEncryptionKeysFromApp(app *backpack.App) error {
|
||||
if app == nil || app.Config == nil {
|
||||
return fmt.Errorf(appKeyErr)
|
||||
}
|
||||
primary, previous, err := LoadAppKey(app.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return PublishEncryptionKeys(app, primary, previous)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ func TestEncryptedRedacts(t *testing.T) {
|
||||
}
|
||||
goRepr := fmt.Sprintf("%#v", e)
|
||||
if strings.Contains(goRepr, secret) {
|
||||
t.Fatalf("GoString/%#v leaked plaintext: %q", goRepr)
|
||||
t.Fatalf("GoString/%%#v leaked plaintext: %q", goRepr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
26
lagoon/keygen.go
Normal file
26
lagoon/keygen.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"git.golem15.com/golem15/summercms/bonfire"
|
||||
)
|
||||
|
||||
// KeyGenerateCommand prints a fresh 32-byte base64 app.key and performs no
|
||||
// other side effect (D-11).
|
||||
func KeyGenerateCommand() bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "key:generate",
|
||||
Description: "Print a fresh 32-byte base64 app.key",
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
key := make([]byte, encryptedKeySize)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Success(base64.StdEncoding.EncodeToString(key))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
105
lagoon/laravel_decrypt.go
Normal file
105
lagoon/laravel_decrypt.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package lagoon
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// laravelPayload is Laravel's Encrypter JSON body (iv/value/mac).
|
||||
type laravelPayload struct {
|
||||
IV string `json:"iv"`
|
||||
Value string `json:"value"`
|
||||
MAC string `json:"mac"`
|
||||
}
|
||||
|
||||
// DecryptLaravelPayload decrypts a Laravel AES-256-CBC+HMAC "encrypted" payload
|
||||
// (base64 JSON {iv,value,mac}) using the raw APP_KEY bytes.
|
||||
//
|
||||
// Cutover-import-only; never called from Encrypted's live Scan/Value path (D-10).
|
||||
func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error) {
|
||||
if len(appKey) != encryptedKeySize {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload key must be 32 bytes")
|
||||
}
|
||||
body, err := decodeLaravelJSON(payloadJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload laravelPayload
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload json: %w", err)
|
||||
}
|
||||
if payload.IV == "" || payload.Value == "" || payload.MAC == "" {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload missing iv, value, or mac")
|
||||
}
|
||||
wantMAC, err := hex.DecodeString(payload.MAC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload mac: %w", err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, appKey)
|
||||
_, _ = mac.Write([]byte(payload.IV + payload.Value))
|
||||
gotMAC := mac.Sum(nil)
|
||||
if !hmac.Equal(wantMAC, gotMAC) {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload mac mismatch")
|
||||
}
|
||||
iv, err := base64.StdEncoding.DecodeString(payload.IV)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload iv: %w", err)
|
||||
}
|
||||
ct, err := base64.StdEncoding.DecodeString(payload.Value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload value: %w", err)
|
||||
}
|
||||
if len(iv) != aes.BlockSize {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload iv length %d", len(iv))
|
||||
}
|
||||
if len(ct) == 0 || len(ct)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload ciphertext length %d", len(ct))
|
||||
}
|
||||
block, err := aes.NewCipher(appKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain := make([]byte, len(ct))
|
||||
cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct)
|
||||
return pkcs7Unpad(plain, aes.BlockSize)
|
||||
}
|
||||
|
||||
func decodeLaravelJSON(payloadJSON string) ([]byte, error) {
|
||||
s := strings.TrimSpace(payloadJSON)
|
||||
if s == "" {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload is empty")
|
||||
}
|
||||
if decoded, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
trimmed := strings.TrimSpace(string(decoded))
|
||||
if strings.HasPrefix(trimmed, "{") {
|
||||
return []byte(trimmed), nil
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(s, "{") {
|
||||
return []byte(s), nil
|
||||
}
|
||||
return nil, fmt.Errorf("lagoon: laravel payload is not base64 JSON")
|
||||
}
|
||||
|
||||
func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) {
|
||||
if blockSize <= 0 || len(b) == 0 || len(b)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload padding")
|
||||
}
|
||||
pad := int(b[len(b)-1])
|
||||
if pad == 0 || pad > blockSize || pad > len(b) {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload padding")
|
||||
}
|
||||
for i := len(b) - pad; i < len(b); i++ {
|
||||
if int(b[i]) != pad {
|
||||
return nil, fmt.Errorf("lagoon: laravel payload padding")
|
||||
}
|
||||
}
|
||||
return b[:len(b)-pad], nil
|
||||
}
|
||||
@@ -61,7 +61,7 @@ func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) {
|
||||
for _, c := range cmds {
|
||||
names[c.Name] = true
|
||||
}
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status"} {
|
||||
for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "key:generate"} {
|
||||
if !names[want] {
|
||||
t.Fatalf("missing %s", want)
|
||||
}
|
||||
@@ -131,11 +131,11 @@ type migPlugin struct {
|
||||
migrations []*gormigrate.Migration
|
||||
}
|
||||
|
||||
func (p migPlugin) ID() string { return p.id }
|
||||
func (p migPlugin) Requires() []string { return nil }
|
||||
func (p migPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Migrations() []*gormigrate.Migration { return p.migrations }
|
||||
func (p migPlugin) ID() string { return p.id }
|
||||
func (p migPlugin) Requires() []string { return nil }
|
||||
func (p migPlugin) Register(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Boot(*backpack.App) error { return nil }
|
||||
func (p migPlugin) Migrations() []*gormigrate.Migration { return p.migrations }
|
||||
|
||||
func TestTwoPluginMigrationSetsIsolated(t *testing.T) {
|
||||
db, _ := dedicatedDB(t, "lagoon_mig_iso")
|
||||
|
||||
Reference in New Issue
Block a user