From 9e8cb60145bddbcf1923b9c1c6ca09c2e2c6bc4a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:31 +0100 Subject: [PATCH 01/10] feat(encryption): encrypt small values bound to where they belong, with older keys still opening them --- internal/encryption/encryption.go | 131 ++++++++ internal/encryption/encryption_test.go | 418 +++++++++++++++++++++++++ 2 files changed, 549 insertions(+) create mode 100644 internal/encryption/encryption.go create mode 100644 internal/encryption/encryption_test.go diff --git a/internal/encryption/encryption.go b/internal/encryption/encryption.go new file mode 100644 index 00000000..2b5e739d --- /dev/null +++ b/internal/encryption/encryption.go @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: Elastic-2.0 + +// Package encryption encrypts and decrypts small values with AES-256-GCM under a current key and any previous ones. +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" +) + +// KeySize is the length of an AES-256 key in bytes. +const KeySize = 32 + +// formatVersion is the first byte of every envelope. +const formatVersion = 1 + +// headerSize counts the version byte and the key id byte that lead every envelope. +const headerSize = 2 + +// Encryption errors. +var ( + // ErrNoKey reports a call made while no current key is set. + ErrNoKey = errors.New("encryption: no key is set") + // ErrCannotDecrypt reports a value no key opens with the given associated data. + ErrCannotDecrypt = errors.New("encryption: cannot decrypt") + // ErrUnknownKey reports a value whose key id matches no key that is set. + ErrUnknownKey = fmt.Errorf("%w: no key that is set has the id", ErrCannotDecrypt) +) + +// key is one key that is set beside the id its envelopes carry. +type key struct { + id byte + aead cipher.AEAD +} + +// Keys encrypts with the current key and decrypts with the current or any previous key. +type Keys struct { + keys []key +} + +// New answers the keys that encrypt with current and decrypt with current or any of previous. +func New(current []byte, previous [][]byte) (*Keys, error) { + if len(current) == 0 && len(previous) > 0 { + return nil, errors.New("encryption: previous keys need a current key") + } + if len(current) == 0 { + return &Keys{}, nil + } + keys := &Keys{keys: make([]key, 0, 1+len(previous))} + for i, raw := range append([][]byte{current}, previous...) { + if len(raw) != KeySize { + return nil, fmt.Errorf("encryption: %s: must hold %d bytes, got %d", keyName(i), KeySize, len(raw)) + } + keys.keys = append(keys.keys, newKey(raw)) + } + return keys, nil +} + +// keyName names the key at index i of the current key followed by the previous ones. +func keyName(i int) string { + if i == 0 { + return "current key" + } + return fmt.Sprintf("previous key %d", i) +} + +// newKey answers the AES-256-GCM cipher over a 32 byte raw key beside the id the key itself gives. +func newKey(raw []byte) key { + block, _ := aes.NewCipher(raw) + aead, _ := cipher.NewGCMWithRandomNonce(block) + sum := sha256.Sum256(raw) + return key{id: sum[0], aead: aead} +} + +// Encrypt answers plaintext encrypted under the current key and bound to additionalData. +func (k *Keys) Encrypt(plaintext []byte, additionalData ...[]byte) ([]byte, error) { + if len(k.keys) == 0 { + return nil, ErrNoKey + } + current := k.keys[0] + header := []byte{formatVersion, current.id} + envelope := make([]byte, 0, headerSize+current.aead.Overhead()+len(plaintext)) + return current.aead.Seal(append(envelope, header...), nil, plaintext, associated(header, additionalData)), nil +} + +// Decrypt answers the plaintext of ciphertext, ErrCannotDecrypt when no key opens it with additionalData. +func (k *Keys) Decrypt(ciphertext []byte, additionalData ...[]byte) ([]byte, error) { + if len(k.keys) == 0 { + return nil, ErrNoKey + } + if len(ciphertext) < headerSize { + return nil, fmt.Errorf("%w: %d bytes is shorter than the header", ErrCannotDecrypt, len(ciphertext)) + } + if ciphertext[0] != formatVersion { + return nil, fmt.Errorf("%w: unknown format version %d", ErrCannotDecrypt, ciphertext[0]) + } + return k.open(ciphertext[1], ciphertext[headerSize:], associated(ciphertext[:headerSize], additionalData)) +} + +// open answers what encrypted opens to under the first key with the id that opens it with additionalData. +func (k *Keys) open(id byte, encrypted, additionalData []byte) ([]byte, error) { + matched := false + for _, candidate := range k.keys { + if candidate.id != id { + continue + } + matched = true + if plaintext, err := candidate.aead.Open(nil, nil, encrypted, additionalData); err == nil { + return plaintext, nil + } + } + if !matched { + return nil, fmt.Errorf("%w 0x%02x", ErrUnknownKey, id) + } + return nil, fmt.Errorf("%w: the key with the id 0x%02x does not open it with this associated data", + ErrCannotDecrypt, id) +} + +// associated answers the header followed by every part, each part led by its length as a uvarint. +func associated(header []byte, parts [][]byte) []byte { + joined := append([]byte(nil), header...) + for _, part := range parts { + joined = binary.AppendUvarint(joined, uint64(len(part))) + joined = append(joined, part...) + } + return joined +} diff --git a/internal/encryption/encryption_test.go b/internal/encryption/encryption_test.go new file mode 100644 index 00000000..a22a2bdb --- /dev/null +++ b/internal/encryption/encryption_test.go @@ -0,0 +1,418 @@ +// SPDX-License-Identifier: Elastic-2.0 + +//go:debug fips140=only + +package encryption_test + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/fips140" + "crypto/sha256" + "errors" + "fmt" + "slices" + "strings" + "sync" + "testing" + + "github.com/gopherium/alphone/internal/encryption" +) + +// workspaceSize is the length of a workspace id in bytes. +const workspaceSize = 16 + +// storedKey is the full key of a secret setting the tests encrypt. +const storedKey = "plugin.email.imap.password" + +// testKey answers a 32 byte key filled with b. +func testKey(b byte) []byte { + return bytes.Repeat([]byte{b}, encryption.KeySize) +} + +// keyID answers the id an envelope carries for raw. +func keyID(raw []byte) byte { + sum := sha256.Sum256(raw) + return sum[0] +} + +// mustKeys answers the keys over current and previous, failing the test on an error. +func mustKeys(t *testing.T, current []byte, previous ...[]byte) *encryption.Keys { + t.Helper() + keys, err := encryption.New(current, previous) + if err != nil { + t.Fatalf("New() error = %v, want nil", err) + } + return keys +} + +// mustEncrypt answers plaintext encrypted under keys and bound to additionalData, failing the test on an error. +func mustEncrypt(t *testing.T, keys *encryption.Keys, plaintext string, additionalData ...[]byte) []byte { + t.Helper() + ciphertext, err := keys.Encrypt([]byte(plaintext), additionalData...) + if err != nil { + t.Fatalf("Encrypt() error = %v, want nil", err) + } + return ciphertext +} + +// mustDecrypt answers what ciphertext opens to under keys and additionalData, failing the test on an error. +func mustDecrypt(t *testing.T, keys *encryption.Keys, ciphertext []byte, additionalData ...[]byte) string { + t.Helper() + plaintext, err := keys.Decrypt(ciphertext, additionalData...) + if err != nil { + t.Fatalf("Decrypt() error = %v, want nil", err) + } + return string(plaintext) +} + +// settingBinding answers the associated data of a stored secret in the workspace filled with workspace. +func settingBinding(workspace byte, key string) [][]byte { + return [][]byte{[]byte("setting"), bytes.Repeat([]byte{workspace}, workspaceSize), []byte(key)} +} + +// pluginBinding answers the associated data of a plugin value in the workspace filled with workspace. +func pluginBinding(workspace byte, plugin, purpose string) [][]byte { + return [][]byte{ + []byte("plugin"), bytes.Repeat([]byte{workspace}, workspaceSize), []byte(plugin), []byte(purpose), + } +} + +// wantCannotDecrypt fails the test unless err reports a value that does not open. +func wantCannotDecrypt(t *testing.T, err error) { + t.Helper() + if !errors.Is(err, encryption.ErrCannotDecrypt) { + t.Errorf("Decrypt() error = %v, want %v", err, encryption.ErrCannotDecrypt) + } +} + +func TestEveryEncryptionTestRunsInFIPSOnlyMode(t *testing.T) { + t.Parallel() + + if !fips140.Enforced() { + t.Fatal("fips140.Enforced() = false, want the tests run under fips140=only") + } + block, err := aes.NewCipher(testKey(1)) + if err != nil { + t.Fatalf("aes.NewCipher() error = %v, want nil", err) + } + if _, err := cipher.NewGCM(block); err == nil { + t.Error("cipher.NewGCM() built a cipher, want FIPS 140-only mode to turn down a caller nonce") + } +} + +func TestAValueRoundTripsUnderTheCurrentKey(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + binding := settingBinding(1, storedKey) + + ciphertext := mustEncrypt(t, keys, "correct horse battery", binding...) + + if got := mustDecrypt(t, keys, ciphertext, binding...); got != "correct horse battery" { + t.Errorf("Decrypt() = %q, want the value encrypted", got) + } +} + +func TestAnEmptyValueRoundTrips(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + + if got := mustDecrypt(t, keys, mustEncrypt(t, keys, "")); got != "" { + t.Errorf("Decrypt() = %q, want the empty value encrypted", got) + } +} + +func TestTheEnvelopeCarriesTheVersionTheKeyIDTheNonceAndTheTag(t *testing.T) { + t.Parallel() + + ciphertext := mustEncrypt(t, mustKeys(t, testKey(1)), "abc") + + if ciphertext[0] != 1 || ciphertext[1] != keyID(testKey(1)) { + t.Errorf("header = %x, want 01%02x", ciphertext[:2], keyID(testKey(1))) + } + if want := 2 + 12 + len("abc") + 16; len(ciphertext) != want { + t.Errorf("len(ciphertext) = %d, want %d", len(ciphertext), want) + } +} + +func TestTheAssociatedDataIsTheHeaderThenEachPartLedByItsLength(t *testing.T) { + t.Parallel() + + ciphertext := mustEncrypt(t, mustKeys(t, testKey(1)), "correct horse battery", []byte("setting"), []byte("ab")) + block, err := aes.NewCipher(testKey(1)) + if err != nil { + t.Fatalf("aes.NewCipher() error = %v, want nil", err) + } + aead, err := cipher.NewGCMWithRandomNonce(block) + if err != nil { + t.Fatalf("cipher.NewGCMWithRandomNonce() error = %v, want nil", err) + } + joined := slices.Concat(ciphertext[:2], []byte{7}, []byte("setting"), []byte{2}, []byte("ab")) + + if plaintext, err := aead.Open(nil, nil, ciphertext[2:], joined); err != nil || + string(plaintext) != "correct horse battery" { + t.Errorf("Open() over the header and the parts = %q, %v, want the value encrypted", plaintext, err) + } + if _, err := aead.Open(nil, nil, ciphertext[2:], joined[2:]); err == nil { + t.Error("Open() over the parts alone opened the value, want the header bound too") + } +} + +func TestTwoEncryptionsOfOneValueDiffer(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + + if bytes.Equal(mustEncrypt(t, keys, "same"), mustEncrypt(t, keys, "same")) { + t.Error("two encryptions of one value are equal, want a new nonce each time") + } +} + +func TestDifferentAssociatedDataDoesNotOpen(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + ciphertext := mustEncrypt(t, keys, "correct horse battery", settingBinding(1, storedKey)...) + tests := map[string][][]byte{ + "another workspace": settingBinding(2, storedKey), + "another setting": settingBinding(1, "plugin.email.smtp.password"), + "a plugin value": pluginBinding(1, "email", "imap.password"), + "no associated data": nil, + "one part left out": settingBinding(1, storedKey)[:2], + "one part added at end": append(settingBinding(1, storedKey), []byte("extra")), + } + + for name, binding := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := keys.Decrypt(ciphertext, binding...) + + wantCannotDecrypt(t, err) + }) + } +} + +func TestAnotherPurposeOfOnePluginDoesNotOpen(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + ciphertext := mustEncrypt(t, keys, "token", pluginBinding(1, "whatsapp", "access_token")...) + + _, err := keys.Decrypt(ciphertext, pluginBinding(1, "whatsapp", "refresh_token")...) + + wantCannotDecrypt(t, err) +} + +func TestPartsJoinedDifferentlyDoNotOpen(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + ciphertext := mustEncrypt(t, keys, "correct horse battery", []byte("a.b"), []byte("c")) + + for name, binding := range map[string][][]byte{ + "split after a": {[]byte("a"), []byte("b.c")}, + "joined as one": {[]byte("a.bc")}, + "joined plainly": {[]byte("a.b" + "c")}, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := keys.Decrypt(ciphertext, binding...) + + wantCannotDecrypt(t, err) + }) + } +} + +func TestOneChangedByteDoesNotOpen(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + binding := settingBinding(1, storedKey) + ciphertext := mustEncrypt(t, keys, "correct horse battery", binding...) + + for at := range ciphertext { + changed := bytes.Clone(ciphertext) + changed[at] ^= 0x01 + + if _, err := keys.Decrypt(changed, binding...); !errors.Is(err, encryption.ErrCannotDecrypt) { + t.Errorf("byte %d changed: Decrypt() error = %v, want %v", at, err, encryption.ErrCannotDecrypt) + } + } +} + +func TestAShortInputDoesNotOpen(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + ciphertext := mustEncrypt(t, keys, "correct horse battery") + + for _, length := range []int{0, 1, 2, 13, 29, len(ciphertext) - 1} { + if _, err := keys.Decrypt(ciphertext[:length]); !errors.Is(err, encryption.ErrCannotDecrypt) { + t.Errorf("%d bytes: Decrypt() error = %v, want %v", length, err, encryption.ErrCannotDecrypt) + } + } +} + +func TestAnUnknownVersionDoesNotOpenAndIsNamed(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + ciphertext := mustEncrypt(t, keys, "correct horse battery") + ciphertext[0] = 2 + + _, err := keys.Decrypt(ciphertext) + + wantCannotDecrypt(t, err) + if err == nil || !strings.Contains(err.Error(), "unknown format version 2") { + t.Errorf("Decrypt() error = %v, want the version 2 named", err) + } +} + +func TestAValueOpensAfterItsKeyMovesToThePreviousList(t *testing.T) { + t.Parallel() + + ciphertext := mustEncrypt(t, mustKeys(t, testKey(1)), "older value") + + rotated := mustKeys(t, testKey(2), testKey(1)) + + if got := mustDecrypt(t, rotated, ciphertext); got != "older value" { + t.Errorf("Decrypt() = %q, want the value encrypted before the rotation", got) + } +} + +func TestANewValueTakesTheCurrentKeyAndOpensWithItAlone(t *testing.T) { + t.Parallel() + + ciphertext := mustEncrypt(t, mustKeys(t, testKey(2), testKey(1)), "newer value") + + if ciphertext[1] != keyID(testKey(2)) { + t.Errorf("key id = %02x, want the current key's %02x", ciphertext[1], keyID(testKey(2))) + } + if got := mustDecrypt(t, mustKeys(t, testKey(2)), ciphertext); got != "newer value" { + t.Errorf("Decrypt() with the current key alone = %q, want the newer value", got) + } +} + +func TestAReorderedPreviousListStillOpensEveryValue(t *testing.T) { + t.Parallel() + + values := map[string][]byte{ + "first": mustEncrypt(t, mustKeys(t, testKey(1)), "first"), + "second": mustEncrypt(t, mustKeys(t, testKey(2)), "second"), + } + + for _, keys := range []*encryption.Keys{ + mustKeys(t, testKey(3), testKey(1), testKey(2)), + mustKeys(t, testKey(3), testKey(2), testKey(1)), + } { + for want, ciphertext := range values { + if got := mustDecrypt(t, keys, ciphertext); got != want { + t.Errorf("Decrypt() = %q, want %q", got, want) + } + } + } +} + +func TestAValueUnderAKeyNoLongerListedNamesItsKeyID(t *testing.T) { + t.Parallel() + + ciphertext := mustEncrypt(t, mustKeys(t, testKey(1)), "correct horse battery") + + _, err := mustKeys(t, testKey(2)).Decrypt(ciphertext) + + if !errors.Is(err, encryption.ErrUnknownKey) || !errors.Is(err, encryption.ErrCannotDecrypt) { + t.Errorf("Decrypt() error = %v, want %v wrapping %v", err, encryption.ErrUnknownKey, encryption.ErrCannotDecrypt) + } + if want := fmt.Sprintf("0x%02x", keyID(testKey(1))); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("Decrypt() error = %v, want the key id %s named", err, want) + } +} + +// keysSharingAnID answers two different keys whose ids are the same byte. +func keysSharingAnID(t *testing.T) ([]byte, []byte) { + t.Helper() + seen := map[byte][]byte{} + for i := range 1000 { + raw := sha256.Sum256(fmt.Appendf(nil, "key %d", i)) + if other, held := seen[keyID(raw[:])]; held { + return other, raw[:] + } + seen[keyID(raw[:])] = raw[:] + } + t.Fatal("no two keys of the thousand share an id") + return nil, nil +} + +func TestEveryKeySharingTheIDOfAValueIsTried(t *testing.T) { + t.Parallel() + + first, second := keysSharingAnID(t) + ciphertext := mustEncrypt(t, mustKeys(t, second), "correct horse battery") + + if got := mustDecrypt(t, mustKeys(t, testKey(9), first, second), ciphertext); got != "correct horse battery" { + t.Errorf("Decrypt() = %q, want the key listed after its namesake tried", got) + } +} + +func TestNewTurnsDownAKeyThatIsNot32Bytes(t *testing.T) { + t.Parallel() + + for _, length := range []int{16, 24, 31, 33} { + _, err := encryption.New(make([]byte, length), nil) + if want := fmt.Sprintf("current key: must hold 32 bytes, got %d", length); err == nil || + !strings.Contains(err.Error(), want) { + t.Errorf("New() of a %d byte key error = %v, want %q", length, err, want) + } + } + _, err := encryption.New(testKey(1), [][]byte{testKey(2), make([]byte, 16)}) + if want := "previous key 2: must hold 32 bytes, got 16"; err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("New() of a short previous key error = %v, want %q", err, want) + } +} + +func TestPreviousKeysNeedACurrentKey(t *testing.T) { + t.Parallel() + + if _, err := encryption.New(nil, [][]byte{testKey(1)}); err == nil { + t.Error("New() of previous keys without a current key error = nil, want it turned down") + } +} + +func TestWithoutAKeyNothingEncryptsOrDecrypts(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, nil) + ciphertext := mustEncrypt(t, mustKeys(t, testKey(1)), "correct horse battery") + + if _, err := keys.Encrypt([]byte("correct horse battery")); !errors.Is(err, encryption.ErrNoKey) { + t.Errorf("Encrypt() error = %v, want %v", err, encryption.ErrNoKey) + } + if _, err := keys.Decrypt(ciphertext); !errors.Is(err, encryption.ErrNoKey) { + t.Errorf("Decrypt() error = %v, want %v", err, encryption.ErrNoKey) + } +} + +func TestTheKeysServeConcurrentCalls(t *testing.T) { + t.Parallel() + + keys := mustKeys(t, testKey(1)) + var wg sync.WaitGroup + for i := range 16 { + wg.Go(func() { + binding := settingBinding(byte(i), storedKey) + ciphertext, err := keys.Encrypt([]byte("correct horse battery"), binding...) + if err == nil { + _, err = keys.Decrypt(ciphertext, binding...) + } + if err != nil { + t.Errorf("call %d: %v", i, err) + } + }) + } + wg.Wait() +} From 117a123128c8d6c3ca984ec3eb11dafbdc4c535a Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:32 +0100 Subject: [PATCH 02/10] chore(lint): keep the encryption package on the standard library --- .golangci.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.golangci.yml b/.golangci.yml index b2dc8d76..83e04c72 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -39,6 +39,13 @@ linters: - github.com/gopherium/framework/goncierge - github.com/gopherium/framework/gonsole - github.com/gopherium/framework/pluginkit + encryption-purity: + list-mode: strict + files: + - "**/internal/encryption/*.go" + allow: + - $gostd + - github.com/gopherium/alphone/internal/encryption plugins-never-import-internal: list-mode: lax files: From 184b6e781b7f890a59ae70c132dc74b32a83eee5 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:32 +0100 Subject: [PATCH 03/10] feat(sdk): declare the settings store and the encrypter a plugin receives --- sdk/encryption.go | 18 ++++++++++++++++++ sdk/settings.go | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 sdk/encryption.go create mode 100644 sdk/settings.go diff --git a/sdk/encryption.go b/sdk/encryption.go new file mode 100644 index 00000000..a72594ab --- /dev/null +++ b/sdk/encryption.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package sdk + +import "context" + +// Encrypter encrypts a plugin's own values for the workspace each call serves. +type Encrypter interface { + // Encrypt answers plaintext encrypted for the calling workspace, this plugin and purpose. + Encrypt(ctx context.Context, purpose string, plaintext []byte) ([]byte, error) + // Decrypt answers what Encrypt took, ErrCannotDecrypt for another workspace, plugin or purpose. + Decrypt(ctx context.Context, purpose string, ciphertext []byte) ([]byte, error) +} + +// EncrypterConsumer receives the host's encrypter for the plugin. +type EncrypterConsumer interface { + UseEncrypter(encrypter Encrypter) +} diff --git a/sdk/settings.go b/sdk/settings.go new file mode 100644 index 00000000..860b3c9b --- /dev/null +++ b/sdk/settings.go @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package sdk + +import ( + "context" + "errors" +) + +// ErrSecretSetting reports a plain read of a key that holds a secret. +var ErrSecretSetting = errors.New("sdk: the setting holds a secret") + +// ErrPlainSetting reports a secret read of a key that holds a plain value. +var ErrPlainSetting = errors.New("sdk: the setting holds a plain value") + +// ErrNoEncryptionKey reports a secret written or read while the host holds no encryption key. +var ErrNoEncryptionKey = errors.New("sdk: no encryption key, set ALPHONE_ENCRYPTION_KEY") + +// ErrCannotDecrypt reports a value no encryption key opens for the calling workspace, plugin and purpose. +var ErrCannotDecrypt = errors.New("sdk: the value does not decrypt here") + +// SettingStore keeps one plugin's settings for the workspace each call serves. +type SettingStore interface { + // Lookup answers the plain value under key and whether it is set, ErrSecretSetting when it holds a secret. + Lookup(ctx context.Context, key string) (string, bool, error) + // Save stores every plain value or none of them. + Save(ctx context.Context, values map[string]string) error + // LookupSecret answers the secret under key and whether it is set, ErrPlainSetting when it holds a plain value. + LookupSecret(ctx context.Context, key string) (string, bool, error) + // SaveSecrets encrypts and stores every secret or none of them. + SaveSecrets(ctx context.Context, secrets map[string]string) error + // HasSecret answers whether key holds a secret that is not empty, without handing it out. + HasSecret(ctx context.Context, key string) (bool, error) + // LookupPrefix answers the plain values whose keys start with prefix, leaving secrets out. + LookupPrefix(ctx context.Context, prefix string) (map[string]string, error) +} + +// SettingStoreConsumer receives the host's settings store for the plugin. +type SettingStoreConsumer interface { + UseSettingStore(store SettingStore) +} From 1800e31d9bf3df3243fcbaad303d9d9900a56d07 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:33 +0100 Subject: [PATCH 04/10] feat(postgres): keep each workspace's settings, with secrets encrypted --- internal/postgres/db/models.go | 8 + internal/postgres/db/queries.sql.go | 84 +++ .../migrations/00025_create_settings.sql | 15 + internal/postgres/queries.sql | 17 + internal/postgres/settings.go | 176 +++++ internal/postgres/settings_test.go | 665 ++++++++++++++++++ internal/postgres/tenantcolumns_test.go | 1 + internal/postgres/tenantpredicate_test.go | 1 + 8 files changed, 967 insertions(+) create mode 100644 internal/postgres/migrations/00025_create_settings.sql create mode 100644 internal/postgres/settings.go create mode 100644 internal/postgres/settings_test.go diff --git a/internal/postgres/db/models.go b/internal/postgres/db/models.go index fe562160..39cbbfa5 100644 --- a/internal/postgres/db/models.go +++ b/internal/postgres/db/models.go @@ -49,6 +49,14 @@ type CoreContactIdentity struct { AddedServer int64 } +type CoreSetting struct { + TenantID uuid.UUID + Key string + Value pgtype.Text + Encrypted []byte + UpdatedAt time.Time +} + type CoreTask struct { ID uuid.UUID AssigneeID uuid.UUID diff --git a/internal/postgres/db/queries.sql.go b/internal/postgres/db/queries.sql.go index 211e8f9e..7d19fe66 100644 --- a/internal/postgres/db/queries.sql.go +++ b/internal/postgres/db/queries.sql.go @@ -1407,6 +1407,30 @@ func (q *Queries) RevokeAPITokenInAnyTenant(ctx context.Context, arg RevokeAPITo return result.RowsAffected(), nil } +const saveSetting = `-- name: SaveSetting :exec +INSERT INTO core.settings (tenant_id, key, value, encrypted, updated_at) +VALUES ($1, $2::text, $3, $4, now()) +ON CONFLICT (tenant_id, key) DO UPDATE +SET value = EXCLUDED.value, encrypted = EXCLUDED.encrypted, updated_at = EXCLUDED.updated_at +` + +type SaveSettingParams struct { + TenantID uuid.UUID + Key string + Value pgtype.Text + Encrypted []byte +} + +func (q *Queries) SaveSetting(ctx context.Context, arg SaveSettingParams) error { + _, err := q.db.Exec(ctx, saveSetting, + arg.TenantID, + arg.Key, + arg.Value, + arg.Encrypted, + ) + return err +} + const setUserSetting = `-- name: SetUserSetting :exec INSERT INTO core.user_settings (user_id, key, value, tenant_id) VALUES ($1::uuid, $2::text, $3::text, $4) @@ -1430,6 +1454,66 @@ func (q *Queries) SetUserSetting(ctx context.Context, arg SetUserSettingParams) return err } +const setting = `-- name: Setting :one +SELECT value, encrypted +FROM core.settings +WHERE tenant_id = $1 AND key = $2::text +` + +type SettingParams struct { + TenantID uuid.UUID + Key string +} + +type SettingRow struct { + Value pgtype.Text + Encrypted []byte +} + +func (q *Queries) Setting(ctx context.Context, arg SettingParams) (SettingRow, error) { + row := q.db.QueryRow(ctx, setting, arg.TenantID, arg.Key) + var i SettingRow + err := row.Scan(&i.Value, &i.Encrypted) + return i, err +} + +const settingsUnder = `-- name: SettingsUnder :many +SELECT key, value +FROM core.settings +WHERE tenant_id = $1 AND starts_with(key, $2::text) AND value IS NOT NULL +ORDER BY key +` + +type SettingsUnderParams struct { + TenantID uuid.UUID + Prefix string +} + +type SettingsUnderRow struct { + Key string + Value pgtype.Text +} + +func (q *Queries) SettingsUnder(ctx context.Context, arg SettingsUnderParams) ([]SettingsUnderRow, error) { + rows, err := q.db.Query(ctx, settingsUnder, arg.TenantID, arg.Prefix) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SettingsUnderRow + for rows.Next() { + var i SettingsUnderRow + if err := rows.Scan(&i.Key, &i.Value); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const settleWebhookDelivery = `-- name: SettleWebhookDelivery :exec UPDATE core.webhook_deliveries SET status = $2, deliver_after = $3, last_error = $4 diff --git a/internal/postgres/migrations/00025_create_settings.sql b/internal/postgres/migrations/00025_create_settings.sql new file mode 100644 index 00000000..2a5ffc08 --- /dev/null +++ b/internal/postgres/migrations/00025_create_settings.sql @@ -0,0 +1,15 @@ +-- SPDX-License-Identifier: Elastic-2.0 + +-- +goose Up +CREATE TABLE core.settings ( + tenant_id uuid NOT NULL REFERENCES core.tenants (id), + key text NOT NULL CHECK (key ~ '^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*$'), + value text, + encrypted bytea, + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (tenant_id, key), + CHECK ((value IS NULL) <> (encrypted IS NULL)) +); + +-- +goose Down +DROP TABLE core.settings; diff --git a/internal/postgres/queries.sql b/internal/postgres/queries.sql index 59299f18..7189db98 100644 --- a/internal/postgres/queries.sql +++ b/internal/postgres/queries.sql @@ -334,3 +334,20 @@ WHERE user_id = @user_id::uuid AND key = @key::text AND tenant_id = @tenant_id; INSERT INTO core.user_settings (user_id, key, value, tenant_id) VALUES (@user_id::uuid, @key::text, @value::text, @tenant_id) ON CONFLICT (tenant_id, user_id, key) DO UPDATE SET value = EXCLUDED.value; + +-- name: Setting :one +SELECT value, encrypted +FROM core.settings +WHERE tenant_id = @tenant_id AND key = @key::text; + +-- name: SaveSetting :exec +INSERT INTO core.settings (tenant_id, key, value, encrypted, updated_at) +VALUES (@tenant_id, @key::text, @value, @encrypted, now()) +ON CONFLICT (tenant_id, key) DO UPDATE +SET value = EXCLUDED.value, encrypted = EXCLUDED.encrypted, updated_at = EXCLUDED.updated_at; + +-- name: SettingsUnder :many +SELECT key, value +FROM core.settings +WHERE tenant_id = @tenant_id AND starts_with(key, @prefix::text) AND value IS NOT NULL +ORDER BY key; diff --git a/internal/postgres/settings.go b/internal/postgres/settings.go new file mode 100644 index 00000000..78d5de15 --- /dev/null +++ b/internal/postgres/settings.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package postgres + +import ( + "context" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/encryption" + "github.com/gopherium/alphone/internal/postgres/db" + "github.com/gopherium/alphone/sdk" +) + +var ( + _ sdk.SettingStore = (*SettingStore)(nil) + _ sdk.SettingStore = (*prefixedSettings)(nil) +) + +// pluginKeyPrefix leads every key a plugin's settings store keeps, before the plugin id. +const pluginKeyPrefix = "plugin." + +// secretPurpose leads the associated data every stored secret is encrypted with. +const secretPurpose = "setting" + +// SettingStore keeps the settings of every workspace, its secrets encrypted with the keys it holds. +type SettingStore struct { + prefixedSettings +} + +// prefixedSettings keeps the settings whose full keys start with prefix, each key taken and answered without it. +type prefixedSettings struct { + pool *pgxpool.Pool + queries *db.Queries + keys *encryption.Keys + prefix string +} + +// NewSettingStore returns a SettingStore backed by pool that encrypts its secrets with keys. +func NewSettingStore(pool *pgxpool.Pool, keys *encryption.Keys) *SettingStore { + return &SettingStore{prefixedSettings{pool: pool, queries: db.New(pool), keys: keys}} +} + +// ForPlugin answers the settings store of the plugin called id, every key kept under its own prefix. +func (s *SettingStore) ForPlugin(id string) sdk.SettingStore { + plugin := s.prefixedSettings + plugin.prefix = pluginKeyPrefix + id + "." + return &plugin +} + +// Lookup answers the plain value under key and whether it is set, ErrSecretSetting when it holds a secret. +func (s *prefixedSettings) Lookup(ctx context.Context, key string) (string, bool, error) { + held, found, err := s.held(ctx, key) + if err != nil || !found { + return "", false, err + } + if !held.Value.Valid { + return "", false, fmt.Errorf("postgres: read setting %s: %w", key, sdk.ErrSecretSetting) + } + return held.Value.String, true, nil +} + +// Save stores every plain value or none of them. +func (s *prefixedSettings) Save(ctx context.Context, values map[string]string) error { + tenant := sdk.TenantOrDefault(ctx) + rows := make([]db.SaveSettingParams, 0, len(values)) + for _, key := range slices.Sorted(maps.Keys(values)) { + rows = append(rows, db.SaveSettingParams{ + TenantID: tenant, Key: s.prefix + key, Value: pgtype.Text{String: values[key], Valid: true}, + }) + } + return s.write(ctx, rows) +} + +// LookupSecret answers the secret under key and whether it is set, ErrPlainSetting when it holds a plain value. +func (s *prefixedSettings) LookupSecret(ctx context.Context, key string) (string, bool, error) { + held, found, err := s.held(ctx, key) + if err != nil || !found { + return "", false, err + } + if held.Value.Valid { + return "", false, fmt.Errorf("postgres: read secret setting %s: %w", key, sdk.ErrPlainSetting) + } + plaintext, err := s.keys.Decrypt(held.Encrypted, secretBinding(sdk.TenantOrDefault(ctx), s.prefix+key)...) + if err != nil { + return "", false, fmt.Errorf("postgres: read secret setting %s: %w", key, encryptionError(err)) + } + return string(plaintext), true, nil +} + +// SaveSecrets encrypts and stores every secret or none of them. +func (s *prefixedSettings) SaveSecrets(ctx context.Context, secrets map[string]string) error { + tenant := sdk.TenantOrDefault(ctx) + rows := make([]db.SaveSettingParams, 0, len(secrets)) + for _, key := range slices.Sorted(maps.Keys(secrets)) { + encrypted, err := s.keys.Encrypt([]byte(secrets[key]), secretBinding(tenant, s.prefix+key)...) + if err != nil { + return fmt.Errorf("postgres: save secret setting %s: %w", key, encryptionError(err)) + } + rows = append(rows, db.SaveSettingParams{TenantID: tenant, Key: s.prefix + key, Encrypted: encrypted}) + } + return s.write(ctx, rows) +} + +// HasSecret answers whether key holds a secret that is not empty, without handing it out. +func (s *prefixedSettings) HasSecret(ctx context.Context, key string) (bool, error) { + secret, _, err := s.LookupSecret(ctx, key) + return secret != "", err +} + +// LookupPrefix answers the plain values whose keys start with prefix, leaving secrets out. +func (s *prefixedSettings) LookupPrefix(ctx context.Context, prefix string) (map[string]string, error) { + rows, err := s.queries.SettingsUnder(ctx, db.SettingsUnderParams{ + TenantID: sdk.TenantOrDefault(ctx), Prefix: s.prefix + prefix, + }) + if err != nil { + return nil, fmt.Errorf("postgres: read settings under %s: %w", prefix, err) + } + values := make(map[string]string, len(rows)) + for _, row := range rows { + values[strings.TrimPrefix(row.Key, s.prefix)] = row.Value.String + } + return values, nil +} + +// held answers the row stored under key in the calling workspace and whether one is stored. +func (s *prefixedSettings) held(ctx context.Context, key string) (db.SettingRow, bool, error) { + row, err := s.queries.Setting(ctx, db.SettingParams{TenantID: sdk.TenantOrDefault(ctx), Key: s.prefix + key}) + if errors.Is(err, pgx.ErrNoRows) { + return db.SettingRow{}, false, nil + } + if err != nil { + return db.SettingRow{}, false, fmt.Errorf("postgres: read setting %s: %w", key, err) + } + return row, true, nil +} + +// write upserts every row in one transaction, in the order given, storing none when one fails. +func (s *prefixedSettings) write(ctx context.Context, rows []db.SaveSettingParams) error { + if len(rows) == 0 { + return nil + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("postgres: save settings: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + queries := s.queries.WithTx(tx) + for _, row := range rows { + if err := queries.SaveSetting(ctx, row); err != nil { + return fmt.Errorf("postgres: save setting %s: %w", strings.TrimPrefix(row.Key, s.prefix), err) + } + } + return tx.Commit(ctx) +} + +// secretBinding answers the associated data a secret stored under the full key in the workspace is encrypted with. +func secretBinding(tenant uuid.UUID, key string) [][]byte { + return [][]byte{[]byte(secretPurpose), tenant[:], []byte(key)} +} + +// encryptionError answers an error of the encryption keys as the sdk error a plugin reads. +func encryptionError(err error) error { + if errors.Is(err, encryption.ErrNoKey) { + return sdk.ErrNoEncryptionKey + } + return fmt.Errorf("%w: %w", sdk.ErrCannotDecrypt, err) +} diff --git a/internal/postgres/settings_test.go b/internal/postgres/settings_test.go new file mode 100644 index 00000000..493df888 --- /dev/null +++ b/internal/postgres/settings_test.go @@ -0,0 +1,665 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package postgres_test + +import ( + "bytes" + "context" + "database/sql" + "errors" + "fmt" + "maps" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/encryption" + "github.com/gopherium/alphone/internal/postgres" + "github.com/gopherium/alphone/sdk" +) + +// settingsVersion is the migration creating the workspace settings. +const settingsVersion = 25 + +// settingKeys answers the encryption keys whose current key is filled with current, the previous ones with previous. +func settingKeys(t *testing.T, current byte, previous ...byte) *encryption.Keys { + t.Helper() + held := make([][]byte, 0, len(previous)) + for _, fill := range previous { + held = append(held, bytes.Repeat([]byte{fill}, encryption.KeySize)) + } + keys, err := encryption.New(bytes.Repeat([]byte{current}, encryption.KeySize), held) + if err != nil { + t.Fatalf("encryption.New() error = %v, want nil", err) + } + return keys +} + +// noSettingKeys answers encryption keys that hold no key at all. +func noSettingKeys(t *testing.T) *encryption.Keys { + t.Helper() + keys, err := encryption.New(nil, nil) + if err != nil { + t.Fatalf("encryption.New() error = %v, want nil", err) + } + return keys +} + +// readBack is what one read of a setting answered. +type readBack struct { + value string + found bool + err error +} + +// String answers the read as its value, whether it was found and its error. +func (r readBack) String() string { + return fmt.Sprintf("%q found %v error %v", r.value, r.found, r.err) +} + +// settingsIn reads and writes one settings store in the workspace ctx serves. +type settingsIn struct { + t *testing.T + ctx context.Context + store sdk.SettingStore +} + +// save saves values, failing the test on an error. +func (s settingsIn) save(values map[string]string) { + s.t.Helper() + if err := s.store.Save(s.ctx, values); err != nil { + s.t.Fatalf("Save(%v) error = %v, want nil", values, err) + } +} + +// saveSecrets saves secrets, failing the test on an error. +func (s settingsIn) saveSecrets(secrets map[string]string) { + s.t.Helper() + if err := s.store.SaveSecrets(s.ctx, secrets); err != nil { + s.t.Fatalf("SaveSecrets() error = %v, want nil", err) + } +} + +// lookup answers what Lookup reads under key. +func (s settingsIn) lookup(key string) readBack { + value, found, err := s.store.Lookup(s.ctx, key) + return readBack{value: value, found: found, err: err} +} + +// lookupSecret answers what LookupSecret reads under key. +func (s settingsIn) lookupSecret(key string) readBack { + value, found, err := s.store.LookupSecret(s.ctx, key) + return readBack{value: value, found: found, err: err} +} + +// wantPlain fails the test unless key reads want as found. +func (s settingsIn) wantPlain(key, want string) { + s.t.Helper() + if got := s.lookup(key); got != (readBack{value: want, found: true}) { + s.t.Errorf("Lookup(%q) = %+v, want %q found", key, got, want) + } +} + +// wantUnset fails the test unless key reads as unset without an error. +func (s settingsIn) wantUnset(key string) { + s.t.Helper() + if got := s.lookup(key); got != (readBack{}) { + s.t.Errorf("Lookup(%q) = %+v, want it unset", key, got) + } +} + +// wantSecret fails the test unless key opens want as found. +func (s settingsIn) wantSecret(key, want string) { + s.t.Helper() + if got := s.lookupSecret(key); got != (readBack{value: want, found: true}) { + s.t.Errorf("LookupSecret(%q) = %+v, want %q found", key, got, want) + } +} + +// wantNoSecret fails the test unless key holds no secret, answered without an error. +func (s settingsIn) wantNoSecret(key string) { + s.t.Helper() + if got := s.lookupSecret(key); got != (readBack{}) { + s.t.Errorf("LookupSecret(%q) = %+v, want it unset", key, got) + } +} + +// wantHasSecret fails the test unless HasSecret answers want for key without an error. +func (s settingsIn) wantHasSecret(key string, want bool) { + s.t.Helper() + held, err := s.store.HasSecret(s.ctx, key) + if err != nil { + s.t.Errorf("HasSecret(%q) error = %v, want nil", key, err) + } + if held != want { + s.t.Errorf("HasSecret(%q) = %v, want %v", key, held, want) + } +} + +// wantListed fails the test unless LookupPrefix answers want under prefix without an error. +func (s settingsIn) wantListed(prefix string, want map[string]string) { + s.t.Helper() + got, err := s.store.LookupPrefix(s.ctx, prefix) + if err != nil { + s.t.Errorf("LookupPrefix(%q) error = %v, want nil", prefix, err) + } + if !maps.Equal(got, want) { + s.t.Errorf("LookupPrefix(%q) = %v, want %v", prefix, got, want) + } +} + +// wantTurnedDown fails the test unless a read answered target and nothing beside it. +func wantTurnedDown(t *testing.T, got readBack, target error) { + t.Helper() + if !errors.Is(got.err, target) { + t.Errorf("error = %v, want %v", got.err, target) + } + if got.value != "" || got.found { + t.Errorf("read %q, found %v, want nothing beside the error", got.value, got.found) + } +} + +// settingView names one view of the settings a case runs over. +type settingView string + +// The views every case runs over. +const ( + coreView settingView = "core" + pluginView settingView = "plugin" +) + +// viewOf answers the settings store of the view over a fresh database, read outside any workspace. +func viewOf(t *testing.T, view settingView) settingsIn { + t.Helper() + store := postgres.NewSettingStore(newTestPool(t), settingKeys(t, 1)) + if view == pluginView { + return settingsIn{t: t, ctx: t.Context(), store: store.ForPlugin("email")} + } + return settingsIn{t: t, ctx: t.Context(), store: store} +} + +// settingCase is one case a settings store passes in every view. +type settingCase struct { + name string + run func(s settingsIn) +} + +// settingCases are the cases every settings store passes, over the keys a mail plugin keeps. +var settingCases = []settingCase{ + {"ReadsWhatItWrote", settingReadsWhatItWrote}, + {"ReportsAnUnsetKeyAsUnset", settingReportsAnUnsetKeyAsUnset}, + {"TellsAnEmptyValueFromAnUnsetKey", settingTellsAnEmptyValueFromAnUnsetKey}, + {"OverwritesAnExistingKey", settingOverwritesAnExistingKey}, + {"KeepsKeysApart", settingKeepsKeysApart}, + {"StoresSeveralKeysAsOneWrite", settingStoresSeveralKeysAsOneWrite}, + {"ClearsAKey", settingClearsAKey}, + {"MatchesKeysExactly", settingMatchesKeysExactly}, + {"SavesNothingFromNoValues", settingSavesNothingFromNoValues}, +} + +// settingReadsWhatItWrote reads back as found the value it stored under a key. +func settingReadsWhatItWrote(s settingsIn) { + s.save(map[string]string{"imap.host": "imap.example.com"}) + + s.wantPlain("imap.host", "imap.example.com") +} + +// settingReportsAnUnsetKeyAsUnset reads a key nobody stored as unset and empty, without an error. +func settingReportsAnUnsetKeyAsUnset(s settingsIn) { + s.wantUnset("imap.host") +} + +// settingTellsAnEmptyValueFromAnUnsetKey reads a key stored as empty as found. +func settingTellsAnEmptyValueFromAnUnsetKey(s settingsIn) { + s.save(map[string]string{"imap.folder": ""}) + + s.wantPlain("imap.folder", "") +} + +// settingOverwritesAnExistingKey reads back the later of two values written to one key. +func settingOverwritesAnExistingKey(s settingsIn) { + s.save(map[string]string{"imap.host": "mail.example.com"}) + + s.save(map[string]string{"imap.host": "imap.example.com"}) + + s.wantPlain("imap.host", "imap.example.com") +} + +// settingKeepsKeysApart leaves one key as it was when a write names another. +func settingKeepsKeysApart(s settingsIn) { + s.save(map[string]string{"imap.host": "imap.example.com"}) + + s.save(map[string]string{"imap.port": "993"}) + + s.wantPlain("imap.host", "imap.example.com") +} + +// settingStoresSeveralKeysAsOneWrite stores every value of a write that names several keys. +func settingStoresSeveralKeysAsOneWrite(s settingsIn) { + values := map[string]string{"imap.host": "imap.example.com", "imap.port": "993"} + + s.save(values) + + for key, want := range values { + s.wantPlain(key, want) + } +} + +// settingClearsAKey reads a key back as empty once an empty value is written over it. +func settingClearsAKey(s settingsIn) { + s.save(map[string]string{"imap.host": "imap.example.com"}) + s.wantPlain("imap.host", "imap.example.com") + + s.save(map[string]string{"imap.host": ""}) + + s.wantPlain("imap.host", "") +} + +// settingMatchesKeysExactly finds nothing under a key that differs from a stored one in case or a trailing space. +func settingMatchesKeysExactly(s settingsIn) { + s.save(map[string]string{"imap.host": "imap.example.com"}) + s.wantPlain("imap.host", "imap.example.com") + + for _, near := range []string{"Imap.Host", "imap.host "} { + s.wantUnset(near) + } +} + +// settingSavesNothingFromNoValues takes a write of no values without an error and leaves every stored key alone. +func settingSavesNothingFromNoValues(s settingsIn) { + s.save(map[string]string{"imap.host": "imap.example.com"}) + + for _, empty := range []map[string]string{nil, {}} { + s.save(empty) + s.saveSecrets(empty) + } + + s.wantPlain("imap.host", "imap.example.com") +} + +func TestEverySettingsViewPassesTheStoreCases(t *testing.T) { + t.Parallel() + + for _, view := range []settingView{coreView, pluginView} { + for _, tc := range settingCases { + t.Run(string(view)+"/"+tc.name, func(t *testing.T) { + t.Parallel() + + tc.run(viewOf(t, view)) + }) + } + } +} + +func TestAValueSavedInOneWorkspaceIsUnsetInAnother(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewSettingStore(pool, settingKeys(t, 1)).ForPlugin("email") + acme := settingsIn{t: t, ctx: standingIn(t, seededTenant(t, pool)), store: store} + elsewhere := settingsIn{t: t, ctx: t.Context(), store: store} + acme.save(map[string]string{"imap.host": "imap.example.com"}) + acme.saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + + elsewhere.wantUnset("imap.host") + elsewhere.wantNoSecret("imap.password") + elsewhere.wantListed("imap.", map[string]string{}) + acme.wantPlain("imap.host", "imap.example.com") +} + +func TestAPluginReachesNeitherAnotherPluginsKeysNorTheCores(t *testing.T) { + t.Parallel() + + store := postgres.NewSettingStore(newTestPool(t), settingKeys(t, 1)) + core := settingsIn{t: t, ctx: t.Context(), store: store} + email := settingsIn{t: t, ctx: t.Context(), store: store.ForPlugin("email")} + extra := settingsIn{t: t, ctx: t.Context(), store: store.ForPlugin("email-extra")} + core.save(map[string]string{"imap.host": "core.example.com"}) + email.save(map[string]string{"imap.host": "imap.example.com"}) + extra.save(map[string]string{"imap.host": "extra.example.com"}) + + email.wantPlain("imap.host", "imap.example.com") + extra.wantPlain("imap.host", "extra.example.com") + core.wantPlain("imap.host", "core.example.com") + core.wantPlain("plugin.email.imap.host", "imap.example.com") + settingsIn{t: t, ctx: t.Context(), store: store.ForPlugin("calendar")}.wantUnset("imap.host") + email.wantListed("", map[string]string{"imap.host": "imap.example.com"}) +} + +// storeHandingOutStores is a settings store that hands out the store of any plugin. +type storeHandingOutStores interface { + ForPlugin(id string) sdk.SettingStore +} + +func TestAPluginStoreHandsOutNoStoreOfAnotherPlugin(t *testing.T) { + t.Parallel() + + store := postgres.NewSettingStore(newTestPool(t), settingKeys(t, 1)) + + if _, handing := store.ForPlugin("email").(storeHandingOutStores); handing { + t.Error("the email store hands out the store of any plugin, want it to keep its own keys alone") + } +} + +func TestAPlainReadAndASecretReadEachTurnDownTheOtherKind(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + s.save(map[string]string{"imap.host": "imap.example.com"}) + s.saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + + wantTurnedDown(t, s.lookup("imap.password"), sdk.ErrSecretSetting) + wantTurnedDown(t, s.lookupSecret("imap.host"), sdk.ErrPlainSetting) + held, err := s.store.HasSecret(t.Context(), "imap.host") + wantTurnedDown(t, readBack{found: held, err: err}, sdk.ErrPlainSetting) + s.wantSecret("imap.password", "correct horse battery") +} + +// copied names the workspace and the full key a test copies an encrypted value from and to. +type copied struct { + from, to uuid.UUID + key, into string +} + +// copySecret stores the encrypted value the copy names under its other workspace and key, by hand. +func copySecret(t *testing.T, pool *pgxpool.Pool, moved copied) { + t.Helper() + if _, err := pool.Exec(t.Context(), + `INSERT INTO core.settings (tenant_id, key, encrypted) + SELECT $1, $2, encrypted FROM core.settings WHERE tenant_id = $3 AND key = $4`, + moved.to, moved.into, moved.from, moved.key); err != nil { + t.Fatalf("copying the encrypted value: %v", err) + } +} + +func TestAnEncryptedValueCopiedElsewhereDoesNotOpen(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewSettingStore(pool, settingKeys(t, 1)) + acme := seededTenant(t, pool) + mine := settingsIn{t: t, ctx: standingIn(t, acme), store: store.ForPlugin("email")} + mine.saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + held := "plugin.email.imap.password" + copySecret(t, pool, copied{from: acme, to: sdk.DefaultTenantID, key: held, into: held}) + copySecret(t, pool, copied{from: acme, to: acme, key: held, into: "plugin.email.smtp.password"}) + copySecret(t, pool, copied{from: acme, to: acme, key: held, into: "plugin.calendar.imap.password"}) + + for name, read := range map[string]readBack{ + "another workspace": settingsIn{t: t, ctx: t.Context(), store: mine.store}.lookupSecret("imap.password"), + "another key": mine.lookupSecret("smtp.password"), + "another plugin": settingsIn{ + t: t, ctx: mine.ctx, store: store.ForPlugin("calendar"), + }.lookupSecret("imap.password"), + } { + t.Run(name, func(t *testing.T) { + wantTurnedDown(t, read, sdk.ErrCannotDecrypt) + }) + } + mine.wantSecret("imap.password", "correct horse battery") +} + +// storedEncrypted answers the encrypted value the workspace holds under the full key. +func storedEncrypted(t *testing.T, pool *pgxpool.Pool, tenant uuid.UUID, key string) []byte { + t.Helper() + var encrypted []byte + if err := pool.QueryRow(t.Context(), "SELECT encrypted FROM core.settings WHERE tenant_id = $1 AND key = $2", + tenant, key).Scan(&encrypted); err != nil { + t.Fatalf("reading the encrypted value under %s: %v", key, err) + } + return encrypted +} + +func TestAStoredSecretIsBoundToTheSettingLabelItsWorkspaceAndItsFullKey(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + acme := seededTenant(t, pool) + store := postgres.NewSettingStore(pool, settingKeys(t, 1)).ForPlugin("email") + settingsIn{t: t, ctx: standingIn(t, acme), store: store}. + saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + + plaintext, err := settingKeys(t, 1).Decrypt(storedEncrypted(t, pool, acme, "plugin.email.imap.password"), + []byte("setting"), acme[:], []byte("plugin.email.imap.password")) + + if err != nil || string(plaintext) != "correct horse battery" { + t.Errorf("Decrypt() with the setting binding = %q, %v, want the secret saved", plaintext, err) + } +} + +func TestAPlainValueAndASecretTakeEachOthersPlace(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + s.saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + + s.save(map[string]string{"imap.password": "written in plain"}) + s.wantPlain("imap.password", "written in plain") + + s.saveSecrets(map[string]string{"imap.password": "encrypted again"}) + s.wantSecret("imap.password", "encrypted again") +} + +func TestAnEmptySecretReadsFoundAndCountsAsNoSecret(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + s.saveSecrets(map[string]string{"imap.password": "", "smtp.password": "correct horse battery"}) + + s.wantSecret("imap.password", "") + s.wantHasSecret("imap.password", false) + s.wantHasSecret("smtp.password", true) + s.wantHasSecret("pop.password", false) +} + +func TestLookupPrefixReadsThePrefixPlainlyAndLeavesSecretsOut(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + s.save(map[string]string{ + "imap_a.host": "underscore.example.com", "imapxa.host": "letter.example.com", "imap.host": "imap.example.com", + }) + s.saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + + s.wantListed("imap_", map[string]string{"imap_a.host": "underscore.example.com"}) + s.wantListed("imap.", map[string]string{"imap.host": "imap.example.com"}) + s.wantListed("%", map[string]string{}) +} + +// wantKeyFormatCheck fails the test unless err is the key format check of the table naming key. +func wantKeyFormatCheck(t *testing.T, err error, key string) { + t.Helper() + if sqlStateOf(err) != checkViolation { + t.Errorf("error = %v, want the key format check %s", err, checkViolation) + } + if !strings.Contains(fmt.Sprint(err), key) { + t.Errorf("error = %v, want it to name %q", err, key) + } +} + +func TestAWriteWithOneBadKeyStoresNoneOfItsValues(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + + wantKeyFormatCheck(t, s.store.Save(t.Context(), map[string]string{ + "imap.host": "imap.example.com", "smtp.Port": "587", + }), "smtp.Port") + wantKeyFormatCheck(t, s.store.SaveSecrets(t.Context(), map[string]string{ + "imap.password": "correct horse battery", "smtp.Password": "x", + }), "smtp.Password") + + s.wantUnset("imap.host") + s.wantListed("", map[string]string{}) + s.wantHasSecret("imap.password", false) +} + +func TestAWriteStopsAtTheFirstBadKeyInSortedOrder(t *testing.T) { + t.Parallel() + + s := viewOf(t, pluginView) + bad := map[string]string{"smtp.Port": "587", "imap.Host": "imap.example.com"} + + for range 20 { + for name, err := range map[string]error{ + "Save": s.store.Save(t.Context(), bad), + "SaveSecrets": s.store.SaveSecrets(t.Context(), bad), + } { + if got := fmt.Sprint(err); !strings.Contains(got, "imap.Host") || strings.Contains(got, "smtp.Port") { + t.Fatalf("%s() error = %v, want it to name imap.Host, the first bad key in sorted order", name, err) + } + } + } +} + +func TestWithoutAnEncryptionKeySecretsAnswerTheMissingKey(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + settingsIn{t: t, ctx: t.Context(), store: postgres.NewSettingStore(pool, settingKeys(t, 1))}. + saveSecrets(map[string]string{"imap.password": "correct horse battery"}) + keyless := settingsIn{t: t, ctx: t.Context(), store: postgres.NewSettingStore(pool, noSettingKeys(t))} + + err := keyless.store.SaveSecrets(t.Context(), map[string]string{"smtp.password": "correct horse battery"}) + + wantTurnedDown(t, readBack{err: err}, sdk.ErrNoEncryptionKey) + if !strings.Contains(fmt.Sprint(err), "ALPHONE_ENCRYPTION_KEY") { + t.Errorf("SaveSecrets() error = %v, want it to name ALPHONE_ENCRYPTION_KEY", err) + } + wantTurnedDown(t, keyless.lookupSecret("imap.password"), sdk.ErrNoEncryptionKey) + keyless.save(map[string]string{"imap.host": "imap.example.com"}) + keyless.wantPlain("imap.host", "imap.example.com") +} + +func TestASecretSavedUnderAnOlderKeyOpensOnceTheKeyIsReplaced(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + settingsIn{t: t, ctx: t.Context(), store: postgres.NewSettingStore(pool, settingKeys(t, 1))}. + saveSecrets(map[string]string{"imap.password": "older secret"}) + rotated := settingsIn{t: t, ctx: t.Context(), store: postgres.NewSettingStore(pool, settingKeys(t, 2, 1))} + + rotated.saveSecrets(map[string]string{"smtp.password": "newer secret"}) + + rotated.wantSecret("imap.password", "older secret") + settingsIn{t: t, ctx: t.Context(), store: postgres.NewSettingStore(pool, settingKeys(t, 2))}. + wantSecret("smtp.password", "newer secret") +} + +func TestTheSettingStoreReportsAClosedPool(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewSettingStore(pool, settingKeys(t, 1)) + pool.Close() + + s := settingsIn{t: t, ctx: t.Context(), store: store} + _, hasErr := store.HasSecret(t.Context(), "imap.password") + _, listErr := store.LookupPrefix(t.Context(), "imap.") + + for name, err := range map[string]error{ + "Lookup": s.lookup("imap.host").err, + "LookupSecret": s.lookupSecret("imap.password").err, + "HasSecret": hasErr, + "LookupPrefix": listErr, + "Save": store.Save(t.Context(), map[string]string{"imap.host": "x"}), + "SaveSecrets": store.SaveSecrets(t.Context(), map[string]string{"imap.password": "x"}), + } { + if err == nil { + t.Errorf("%s() error = nil, want the closed pool reported", name) + } + } + for name, err := range map[string]error{ + "Save() of no values": store.Save(t.Context(), nil), + "SaveSecrets() of no values": store.SaveSecrets(t.Context(), map[string]string{}), + } { + if err != nil { + t.Errorf("%s error = %v, want nil with nothing to write", name, err) + } + } +} + +func TestTheSettingsTableHoldsAPlainValueOrASecretNeverBoth(t *testing.T) { + t.Parallel() + + db := newTestDB(t) + tests := map[string]struct { + value any + encrypted any + wantCode string + }{ + "a plain value": {"imap.example.com", nil, ""}, + "a secret": {nil, []byte{1}, ""}, + "neither of the two": {nil, nil, checkViolation}, + "both of them at once": {"imap.example.com", []byte{1}, checkViolation}, + } + + for name, tt := range tests { + _, err := db.Exec(`INSERT INTO core.settings (tenant_id, key, value, encrypted) VALUES ($1, $2, $3, $4)`, + sdk.DefaultTenantID, "check."+strings.ReplaceAll(name, " ", "_"), tt.value, tt.encrypted) + if got := sqlStateOf(err); got != tt.wantCode { + t.Errorf("storing %s answered %q (%v), want %q", name, got, err, tt.wantCode) + } + } +} + +func TestTheSettingsTableAdmitsTheKeyFormsBothProductsUse(t *testing.T) { + t.Parallel() + + db := newTestDB(t) + tests := map[string]string{ + "locale.default": "", "site_id": "", "plugin.demo-notes.limit": "", "plugin.email.imap.host": "", + "Locale.default": checkViolation, "locale.": checkViolation, ".locale": checkViolation, + "locale..default": checkViolation, "1locale": checkViolation, "locale default": checkViolation, "": checkViolation, + } + + for key, wantCode := range tests { + _, err := db.Exec(`INSERT INTO core.settings (tenant_id, key, value) VALUES ($1, $2, 'x')`, + sdk.DefaultTenantID, key) + if got := sqlStateOf(err); got != wantCode { + t.Errorf("storing the key %q answered %q (%v), want %q", key, got, err, wantCode) + } + } +} + +func TestEveryWorkspaceSettingNamesAWorkspaceThatExists(t *testing.T) { + t.Parallel() + + db := newTestDB(t) + + _, err := db.Exec(`INSERT INTO core.settings (tenant_id, key, value) VALUES ($1, 'imap.host', 'x')`, + uuid.Must(uuid.NewV7())) + + if got := sqlStateOf(err); got != foreignKeyViolation { + t.Errorf("storing a setting of no workspace answered %q (%v), want %q", got, err, foreignKeyViolation) + } +} + +func TestRollingTheSettingsMigrationBackDropsTheTable(t *testing.T) { + t.Parallel() + + db := newTestDB(t) + if !settingsTableExists(t, db) { + t.Fatal("core.settings is missing before the rollback, want the migration to create it") + } + + if _, err := coreProvider(t, db).DownTo(t.Context(), settingsVersion-1); err != nil { + t.Fatalf("rolling back the settings migration: %v", err) + } + + if settingsTableExists(t, db) { + t.Error("core.settings is still there after the rollback, want it dropped") + } +} + +// settingsTableExists reports whether db holds core.settings. +func settingsTableExists(t *testing.T, db *sql.DB) bool { + t.Helper() + var held bool + if err := db.QueryRow("SELECT to_regclass('core.settings') IS NOT NULL").Scan(&held); err != nil { + t.Fatalf("looking for core.settings: %v", err) + } + return held +} diff --git a/internal/postgres/tenantcolumns_test.go b/internal/postgres/tenantcolumns_test.go index af4353fa..c3775859 100644 --- a/internal/postgres/tenantcolumns_test.go +++ b/internal/postgres/tenantcolumns_test.go @@ -19,6 +19,7 @@ var coreScopedTables = []string{ "webhook_subscriptions", "webhook_deliveries", "user_settings", + "settings", } func TestEveryCoreDataTableCarriesItsTenant(t *testing.T) { diff --git a/internal/postgres/tenantpredicate_test.go b/internal/postgres/tenantpredicate_test.go index 73524a9d..08a952d2 100644 --- a/internal/postgres/tenantpredicate_test.go +++ b/internal/postgres/tenantpredicate_test.go @@ -18,6 +18,7 @@ var tenantGuardedTables = []string{ "user_settings", "webhook_subscriptions", "webhook_deliveries", + "settings", } // authenticatingQueries names the queries resolving a credential before any tenant is known. From 0442ad48767636c25a0264e665f98787556cd46d Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:33 +0100 Subject: [PATCH 05/10] refactor(postgres): tell an unset user setting from an empty one --- internal/postgres/tenantscope_test.go | 46 ++++---- internal/postgres/usersettings.go | 14 +-- internal/postgres/usersettings_test.go | 153 ++++++++++++++++++------- 3 files changed, 139 insertions(+), 74 deletions(-) diff --git a/internal/postgres/tenantscope_test.go b/internal/postgres/tenantscope_test.go index 44ef43b7..5fe1c5e8 100644 --- a/internal/postgres/tenantscope_test.go +++ b/internal/postgres/tenantscope_test.go @@ -170,35 +170,33 @@ func TestTheIdentityFeedStaysInsideItsTenant(t *testing.T) { } } -func TestASettingStaysInsideItsTenant(t *testing.T) { +func TestAUserSettingStaysInsideItsTenant(t *testing.T) { t.Parallel() pool := newTestPool(t) - store := postgres.NewUserSettingStore(pool) - acme := seededTenant(t, pool) - holder := seedSettingsUser(t, pool) - if err := store.SetUserSetting(standingIn(t, acme), holder, "locale.default", "es-ES"); err != nil { - t.Fatalf("SetUserSetting() in Acme error = %v, want nil", err) - } + elsewhere := readerOver(t, pool) + acme := elsewhere.in(standingIn(t, seededTenant(t, pool))) + acme.save("locale.default", "es-ES") - if err := store.SetUserSetting(t.Context(), holder, "locale.default", "en-GB"); err != nil { - t.Fatalf("SetUserSetting() elsewhere error = %v, want the setting admitted", err) - } + elsewhere.save("locale.default", "en-GB") - held, err := store.UserSetting(standingIn(t, acme), holder, "locale.default") - if err != nil { - t.Fatalf("UserSetting() in Acme error = %v, want nil", err) - } - if held != "es-ES" { - t.Errorf("the Acme setting = %q, want it untouched by the other tenant", held) - } - elsewhere, err := store.UserSetting(t.Context(), holder, "locale.default") - if err != nil { - t.Fatalf("UserSetting() elsewhere error = %v, want nil", err) - } - if elsewhere != "en-GB" { - t.Errorf("the other tenant's setting = %q, want its own value stored", elsewhere) - } + acme.want("locale.default", foundAs("es-ES")) + elsewhere.want("locale.default", foundAs("en-GB")) +} + +func TestAWorkspaceSettingStaysInsideItsTenant(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewSettingStore(pool, settingKeys(t, 1)).ForPlugin("email") + acme := settingsIn{t: t, ctx: standingIn(t, seededTenant(t, pool)), store: store} + elsewhere := settingsIn{t: t, ctx: t.Context(), store: store} + acme.save(map[string]string{"imap.host": "imap.example.com"}) + + elsewhere.save(map[string]string{"imap.host": "mail.example.com"}) + + acme.wantPlain("imap.host", "imap.example.com") + elsewhere.wantPlain("imap.host", "mail.example.com") } func TestATaskOriginReplaysInsideItsTenantAlone(t *testing.T) { diff --git a/internal/postgres/usersettings.go b/internal/postgres/usersettings.go index 0abcbed6..9ea408b9 100644 --- a/internal/postgres/usersettings.go +++ b/internal/postgres/usersettings.go @@ -23,22 +23,22 @@ func NewUserSettingStore(pool *pgxpool.Pool) *UserSettingStore { return &UserSettingStore{queries: db.New(pool)} } -// UserSetting returns the stored value under key, empty when the user set none. -func (s *UserSettingStore) UserSetting(ctx context.Context, userID uuid.UUID, key string) (string, error) { +// Lookup answers the value stored under key for the user and whether one is stored. +func (s *UserSettingStore) Lookup(ctx context.Context, userID uuid.UUID, key string) (string, bool, error) { values, err := s.queries.UserSetting(ctx, db.UserSettingParams{ UserID: userID, Key: key, TenantID: sdk.TenantOrDefault(ctx), }) if err != nil { - return "", fmt.Errorf("read setting %s: %w", key, err) + return "", false, fmt.Errorf("read setting %s: %w", key, err) } if len(values) == 0 { - return "", nil + return "", false, nil } - return values[0], nil + return values[0], true, nil } -// SetUserSetting stores value under key for the user, replacing any earlier value. -func (s *UserSettingStore) SetUserSetting(ctx context.Context, userID uuid.UUID, key, value string) error { +// Save stores value under key for the user, replacing any earlier value. +func (s *UserSettingStore) Save(ctx context.Context, userID uuid.UUID, key, value string) error { err := s.queries.SetUserSetting(ctx, db.SetUserSettingParams{ UserID: userID, Key: key, Value: value, TenantID: sdk.TenantOrDefault(ctx), }) diff --git a/internal/postgres/usersettings_test.go b/internal/postgres/usersettings_test.go index a3698888..312c0289 100644 --- a/internal/postgres/usersettings_test.go +++ b/internal/postgres/usersettings_test.go @@ -3,6 +3,7 @@ package postgres_test import ( + "context" "testing" "github.com/google/uuid" @@ -23,75 +24,141 @@ func seedSettingsUser(t *testing.T, pool *pgxpool.Pool) uuid.UUID { return id } -func TestUserSettingRoundTrips(t *testing.T) { - t.Parallel() - - pool := newTestPool(t) - store := postgres.NewUserSettingStore(pool) - user := seedSettingsUser(t, pool) +// userSettingsOf reads and writes the settings of one user in the workspace ctx serves. +type userSettingsOf struct { + t *testing.T + ctx context.Context + store *postgres.UserSettingStore + user uuid.UUID +} - if err := store.SetUserSetting(t.Context(), user, "locale.default", "es-ES"); err != nil { - t.Fatalf("SetUserSetting() error = %v, want nil", err) +// save stores value under key, failing the test on an error. +func (u userSettingsOf) save(key, value string) { + u.t.Helper() + if err := u.store.Save(u.ctx, u.user, key, value); err != nil { + u.t.Fatalf("Save(%q, %q) error = %v, want nil", key, value, err) } +} - got, err := store.UserSetting(t.Context(), user, "locale.default") - if err != nil { - t.Fatalf("UserSetting() error = %v, want nil", err) +// want fails the test unless key reads back as want says. +func (u userSettingsOf) want(key string, want readBack) { + u.t.Helper() + held, found, err := u.store.Lookup(u.ctx, u.user, key) + if got := (readBack{value: held, found: found, err: err}); got != want { + u.t.Errorf("Lookup(%q) = %+v, want %+v", key, got, want) } - if got != "es-ES" { - t.Errorf("value = %q, want the stored choice back", got) +} + +// in answers the same user's settings in the workspace ctx serves. +func (u userSettingsOf) in(ctx context.Context) userSettingsOf { + u.ctx = ctx + return u +} + +// readerOver answers the settings of one new user over pool, read outside any workspace. +func readerOver(t *testing.T, pool *pgxpool.Pool) userSettingsOf { + t.Helper() + return userSettingsOf{ + t: t, ctx: t.Context(), store: postgres.NewUserSettingStore(pool), user: seedSettingsUser(t, pool), } } -func TestUserSettingAnswersEmptyWhenUnset(t *testing.T) { +// foundAs answers the read of a value stored as value. +func foundAs(value string) readBack { + return readBack{value: value, found: true} +} + +func TestAUserSettingReadsWhatItWrote(t *testing.T) { t.Parallel() - store := postgres.NewUserSettingStore(newTestPool(t)) + reader := readerOver(t, newTestPool(t)) - got, err := store.UserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default") + reader.save("locale.default", "es-ES") - if err != nil { - t.Fatalf("UserSetting() error = %v, want nil", err) - } - if got != "" { - t.Errorf("value = %q, want empty for an account that chose nothing", got) - } + reader.want("locale.default", foundAs("es-ES")) } -func TestUserSettingReportsAClosedPool(t *testing.T) { +func TestAUserSettingCarriesTheLatestWrite(t *testing.T) { + t.Parallel() + + reader := readerOver(t, newTestPool(t)) + reader.save("locale.default", "es-ES") + + reader.save("locale.default", "en-US") + + reader.want("locale.default", foundAs("en-US")) +} + +func TestAUserSettingIsTheReadersAlone(t *testing.T) { t.Parallel() pool := newTestPool(t) - store := postgres.NewUserSettingStore(pool) - pool.Close() + mine, theirs := readerOver(t, pool), readerOver(t, pool) - if _, err := store.UserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default"); err == nil { - t.Error("UserSetting() error = nil, want the closed pool reported") - } - if err := store.SetUserSetting(t.Context(), uuid.Must(uuid.NewV7()), "locale.default", "es-ES"); err == nil { - t.Error("SetUserSetting() error = nil, want the closed pool reported") + mine.save("locale.default", "es-ES") + + theirs.want("locale.default", readBack{}) + mine.want("locale.default", foundAs("es-ES")) +} + +func TestAUserSettingAnswersNothingForAKeyItNeverHeld(t *testing.T) { + t.Parallel() + + userWithNoSettings := userSettingsOf{ + t: t, ctx: t.Context(), store: postgres.NewUserSettingStore(newTestPool(t)), user: uuid.Must(uuid.NewV7()), } + + userWithNoSettings.want("locale.default", readBack{}) } -func TestUserSettingKeepsOnlyTheLastWrite(t *testing.T) { +func TestAUserSettingTellsAnEmptyValueFromAnUnsetKey(t *testing.T) { t.Parallel() - pool := newTestPool(t) - store := postgres.NewUserSettingStore(pool) - user := seedSettingsUser(t, pool) + reader := readerOver(t, newTestPool(t)) + + reader.save("locale.default", "") - if err := store.SetUserSetting(t.Context(), user, "locale.default", "es-ES"); err != nil { - t.Fatalf("SetUserSetting() error = %v, want nil", err) + reader.want("locale.default", foundAs("")) +} + +func TestAUserSettingKeepsEachKeyApart(t *testing.T) { + t.Parallel() + + reader := readerOver(t, newTestPool(t)) + for _, write := range []struct{ key, value string }{ + {"locale.default", "es-ES"}, {"list.page_size", "50"}, {"locale.default", "en-US"}, + } { + reader.save(write.key, write.value) } - if err := store.SetUserSetting(t.Context(), user, "locale.default", "en-US"); err != nil { - t.Fatalf("SetUserSetting() again error = %v, want nil", err) + + reader.want("locale.default", foundAs("en-US")) + reader.want("list.page_size", foundAs("50")) +} + +func TestAUserSettingMatchesKeysExactly(t *testing.T) { + t.Parallel() + + reader := readerOver(t, newTestPool(t)) + + reader.save("locale.default", "es-ES") + + for _, near := range []string{"Locale.Default", "locale.default "} { + reader.want(near, readBack{}) } + reader.want("locale.default", foundAs("es-ES")) +} + +func TestTheUserSettingStoreReportsAClosedPool(t *testing.T) { + t.Parallel() + + pool := newTestPool(t) + store := postgres.NewUserSettingStore(pool) + pool.Close() - got, err := store.UserSetting(t.Context(), user, "locale.default") - if err != nil { - t.Fatalf("UserSetting() error = %v, want nil", err) + if _, _, err := store.Lookup(t.Context(), uuid.Must(uuid.NewV7()), "locale.default"); err == nil { + t.Error("Lookup() error = nil, want the closed pool reported") } - if got != "en-US" { - t.Errorf("value = %q, want the later write to win", got) + if err := store.Save(t.Context(), uuid.Must(uuid.NewV7()), "locale.default", "es-ES"); err == nil { + t.Error("Save() error = nil, want the closed pool reported") } } From 7c46d2328e6098679d71375b96525a9022f3d910 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:34 +0100 Subject: [PATCH 06/10] refactor(graphres): read and save user settings through Lookup and Save --- internal/graphres/graphres.go | 2 +- internal/graphres/locale.go | 12 ++++++------ internal/graphres/locale_test.go | 10 +++++----- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/graphres/graphres.go b/internal/graphres/graphres.go index 815ce41f..2ee429bf 100644 --- a/internal/graphres/graphres.go +++ b/internal/graphres/graphres.go @@ -222,7 +222,7 @@ type Resolver struct { // Roles answers which roles the deployment declares and the capabilities each carries. Roles *goncierge.Registry // Settings reads and writes the caller's stored preferences. - Settings SettingStore + Settings UserSettingStore // LoginLimiter counts failed logins per client IP. LoginLimiter AttemptLimiter // Invites serves the invitation and reset flows. diff --git a/internal/graphres/locale.go b/internal/graphres/locale.go index 62571151..d2dcdad6 100644 --- a/internal/graphres/locale.go +++ b/internal/graphres/locale.go @@ -15,17 +15,17 @@ import ( // localeKey names the setting the reader's language choice is stored under. const localeKey = "locale.default" -// SettingStore reads and writes one per-user setting. -type SettingStore interface { - UserSetting(ctx context.Context, userID uuid.UUID, key string) (string, error) - SetUserSetting(ctx context.Context, userID uuid.UUID, key, value string) error +// UserSettingStore reads and writes one per-user setting. +type UserSettingStore interface { + Lookup(ctx context.Context, userID uuid.UUID, key string) (string, bool, error) + Save(ctx context.Context, userID uuid.UUID, key, value string) error } // Locale answers the locale the caller is served in. func (q QueryResolvers) Locale(ctx context.Context) (string, error) { stored := "" if id := authkit.IdentityFromContext(ctx).ID; id != uuid.Nil && q.root.Settings != nil { - held, err := q.root.Settings.UserSetting(ctx, id, localeKey) + held, _, err := q.root.Settings.Lookup(ctx, id, localeKey) if err != nil { return "", err } @@ -49,7 +49,7 @@ func (m MutationResolvers) SetLocale(ctx context.Context, chosen string) (string return "", err } id := authkit.IdentityFromContext(ctx).ID - if err := m.root.Settings.SetUserSetting(ctx, id, localeKey, chosen); err != nil { + if err := m.root.Settings.Save(ctx, id, localeKey, chosen); err != nil { return "", err } return chosen, nil diff --git a/internal/graphres/locale_test.go b/internal/graphres/locale_test.go index e8da89c5..207db7b2 100644 --- a/internal/graphres/locale_test.go +++ b/internal/graphres/locale_test.go @@ -76,13 +76,13 @@ func TestSetLocaleRoundTripsForASignedInCaller(t *testing.T) { // failingSettings is a settings store whose reads and writes always fail. type failingSettings struct{} -// UserSetting always reports the store unreachable. -func (failingSettings) UserSetting(context.Context, uuid.UUID, string) (string, error) { - return "", errSettingsDown +// Lookup always reports the store unreachable. +func (failingSettings) Lookup(context.Context, uuid.UUID, string) (string, bool, error) { + return "", false, errSettingsDown } -// SetUserSetting always reports the store unreachable. -func (failingSettings) SetUserSetting(context.Context, uuid.UUID, string, string) error { +// Save always reports the store unreachable. +func (failingSettings) Save(context.Context, uuid.UUID, string, string) error { return errSettingsDown } From f9215d25ad358954b9cee7203b9a61b3eb1d48c3 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:34 +0100 Subject: [PATCH 07/10] test(features): keep each workspace's settings apart and each plugin's out of reach of the others --- .../features/workspace-settings.feature | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/features/features/workspace-settings.feature diff --git a/test/features/features/workspace-settings.feature b/test/features/features/workspace-settings.feature new file mode 100644 index 00000000..3887d440 --- /dev/null +++ b/test/features/features/workspace-settings.feature @@ -0,0 +1,32 @@ +Feature: A plugin keeps the settings of each workspace apart + A plugin that needs the settings each workspace chooses, such as its + mailbox address and password, keeps them through the settings store + AlphOne hands it. A setting belongs to the workspace that saved it and + to the plugin that saved it. A secret setting is encrypted with the key + the server is given, and it opens only in the workspace and under the + setting it was saved for. + + Background: + Given the database is migrated + And the server holds an encryption key + And the workspace "Acme" exists + And the workspace "Globex" exists + And AlphOne runs a mail plugin and a calendar plugin that keep settings + + Scenario: Two workspaces keep a setting apart + When the workspace "Acme" saves the mailbox host "imap.example.com" + And the workspace "Globex" saves the mailbox host "mail.example.com" + Then the workspace "Acme" reads the mailbox host "imap.example.com" + And the workspace "Globex" reads the mailbox host "mail.example.com" + + Scenario: A secret copied to another workspace does not open + Given the workspace "Acme" saves the mailbox password "correct horse battery" + When the mailbox password of "Acme" is copied to "Globex" + Then the workspace "Globex" cannot open its mailbox password + And the workspace "Acme" still opens its mailbox password as "correct horse battery" + + Scenario: A plugin never reaches the settings of another plugin + Given the workspace "Acme" saves the mailbox password "correct horse battery" + When the calendar plugin asks its store for the store of the mail plugin + Then the calendar plugin gets no store of the mail plugin + And the calendar plugin reads no mailbox password in "Acme" From b4a9ab0b59224861acb305f6e217a9b8580229f4 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:34 +0100 Subject: [PATCH 08/10] feat(cmd): read the encryption keys and hand each plugin a settings store and an encrypter of its own --- .env.example | 5 + cmd/alphone/compose.go | 4 + cmd/alphone/run.go | 6 +- cmd/alphone/settings_test.go | 58 ++++ cmd/alphone/settingstore.go | 119 +++++++ cmd/alphone/settingstorewiring_test.go | 314 ++++++++++++++++++ cmd/alphone/workspacesettings_feature_test.go | 224 +++++++++++++ 7 files changed, 729 insertions(+), 1 deletion(-) create mode 100644 cmd/alphone/settingstore.go create mode 100644 cmd/alphone/settingstorewiring_test.go create mode 100644 cmd/alphone/workspacesettings_feature_test.go diff --git a/.env.example b/.env.example index 586fd0b9..f43d75da 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,11 @@ ALPHONE_TEST_S3_SECRET_ACCESS_KEY=0123456789abcdef0123456789abcdef0123456789abcd # Comma-separated CIDR ranges of reverse proxies allowed for login rate limiting. # ALPHONE_TRUSTED_PROXIES=10.0.0.0/8 +# The key that encrypts the secrets plugins keep, 64 hex characters from openssl rand -hex 32, none when unset. +# ALPHONE_ENCRYPTION_KEY= +# The keys that still open the secrets saved before the current key, a comma list, none when unset. +# ALPHONE_ENCRYPTION_PREVIOUS_KEYS= + # Internal addresses webhooks may still reach, none when unset. A comma list of CIDR ranges # such as 127.0.0.1/32 and host names with a port such as n8n:5678. Link-local and cloud # metadata addresses stay refused whatever this lists. The local n8n of make n8n listens on diff --git a/cmd/alphone/compose.go b/cmd/alphone/compose.go index 2f122269..84cc8f5d 100644 --- a/cmd/alphone/compose.go +++ b/cmd/alphone/compose.go @@ -51,6 +51,7 @@ type composed struct { roles *goncierge.Registry files *storage.Files sweeper *storage.Sweeper + settings *postgres.SettingStore registered []sdk.Plugin failed error } @@ -77,6 +78,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s roles: cfg.roles, files: storage.NewFiles(cfg.storage, pool), sweeper: storage.NewSweeper(cfg.storage, pool, cfg.logger), + settings: postgres.NewSettingStore(pool, cfg.encryption), } built.events = nudgingPublisher{ dispatcher: webhook.NewDispatcher(webhooks, cfg.logger), worker: built.worker, hub: built.hub, @@ -115,6 +117,8 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s wireRoleChecker(built.registered, roleCheckerBridge{registry: cfg.roles}) wireMailSenderFrom(built.registered, sender) wireFileStores(built.registered, built.files) + wireSettingStores(built.registered, built.settings) + wireEncrypters(built.registered, cfg.encryption) return built, nil } diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index a393144c..56acf2e6 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -23,6 +23,7 @@ import ( "github.com/gopherium/alphone/graph" "github.com/gopherium/alphone/internal/contact" + "github.com/gopherium/alphone/internal/encryption" "github.com/gopherium/alphone/internal/graphres" "github.com/gopherium/alphone/internal/graphroot" "github.com/gopherium/alphone/internal/postgres" @@ -291,6 +292,7 @@ type composeSettings struct { webhookHosts webhook.AllowList paging graphres.Paging storage storage.Settings + encryption *encryption.Keys } // servingDefaults are the HTTP timeouts and shutdown graces the server runs under when the environment names none. @@ -497,10 +499,12 @@ func loadComposeSettings(env gonsole.Env) (composeSettings, error) { if err != nil { return composeSettings{}, err } + keys, err := loadEncryptionKeys(env) return composeSettings{ machineGrace: machineGrace, tenants: tenants, contactMatchMax: contactMatchMax, publicURL: publicURL, mail: mail, webhookHosts: webhookHosts, paging: paging, storage: files, - }, nil + encryption: keys, + }, err } // loadRunConfig reads the server settings from the environment. diff --git a/cmd/alphone/settings_test.go b/cmd/alphone/settings_test.go index ba173d23..67f68e54 100644 --- a/cmd/alphone/settings_test.go +++ b/cmd/alphone/settings_test.go @@ -87,6 +87,8 @@ func coreSettings() map[string]string { "ALPHONE_STORAGE_S3_SECRET_ACCESS_KEY": "example-secret", "ALPHONE_STORAGE_S3_PATH_STYLE": "true", "ALPHONE_STORAGE_S3_CHECKSUM": "when_required", + "ALPHONE_ENCRYPTION_KEY": currentEncryptionKey, + "ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey, } } @@ -616,6 +618,62 @@ func TestTheExampleFileStatesTheCommandFallbacks(t *testing.T) { } } +func TestABadEncryptionKeyIsNamedWithoutShowingIt(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + env map[string]string + want string + }{ + "a key that is not hex": { + map[string]string{"ALPHONE_ENCRYPTION_KEY": strings.Repeat("zz", 32)}, + "ALPHONE_ENCRYPTION_KEY: must be written in hex, as openssl rand -hex 32 prints it", + }, + "a key too short": { + map[string]string{"ALPHONE_ENCRYPTION_KEY": currentEncryptionKey[:32]}, + "ALPHONE_ENCRYPTION_KEY: must hold 32 bytes, got 16", + }, + "a bad entry in the previous list": { + map[string]string{ + "ALPHONE_ENCRYPTION_KEY": currentEncryptionKey, + "ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey + ",0a0b", + }, + "ALPHONE_ENCRYPTION_PREVIOUS_KEYS: entry 2: must hold 32 bytes, got 2", + }, + "previous keys without a current key": { + map[string]string{"ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey}, + "ALPHONE_ENCRYPTION_PREVIOUS_KEYS is set but ALPHONE_ENCRYPTION_KEY is not", + }, + } + for testName, tt := range tests { + t.Run(testName, func(t *testing.T) { + t.Parallel() + + env := coreSettings() + delete(env, "ALPHONE_ENCRYPTION_KEY") + delete(env, "ALPHONE_ENCRYPTION_PREVIOUS_KEYS") + maps.Copy(env, tt.env) + + _, err := loadRunConfig(testkit.Getenv(env)) + + if err == nil || err.Error() != tt.want { + t.Fatalf("loadRunConfig() error = %v, want %q", err, tt.want) + } + wantNoValueShown(t, err, tt.env) + }) + } +} + +// wantNoValueShown fails the test when err shows any value env holds. +func wantNoValueShown(t *testing.T, err error, env map[string]string) { + t.Helper() + for key, value := range env { + if strings.Contains(err.Error(), value) { + t.Errorf("error = %q, want it to leave the value of %s out", err, key) + } + } +} + func TestSettingRefusalsNameTheSettingAndTheReason(t *testing.T) { t.Parallel() diff --git a/cmd/alphone/settingstore.go b/cmd/alphone/settingstore.go new file mode 100644 index 00000000..38d06b2a --- /dev/null +++ b/cmd/alphone/settingstore.go @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + + "github.com/gopherium/framework/gonsole" + + "github.com/gopherium/alphone/internal/encryption" + "github.com/gopherium/alphone/internal/postgres" + "github.com/gopherium/alphone/sdk" +) + +// encryptionKeySetting names the setting holding the key new secrets are encrypted with, past the program prefix. +const encryptionKeySetting = "ENCRYPTION_KEY" + +// previousEncryptionKeysSetting names the setting listing the keys older secrets still open with. +const previousEncryptionKeysSetting = "ENCRYPTION_PREVIOUS_KEYS" + +// pluginPurpose leads the associated data every value a plugin encrypts is bound to. +const pluginPurpose = "plugin" + +// loadEncryptionKeys reads the key new secrets are encrypted with and the keys older ones still open with. +func loadEncryptionKeys(env gonsole.Env) (*encryption.Keys, error) { + current, err := gonsole.Parse(env, encryptionKeySetting, nil, parseEncryptionKey) + if err != nil { + return nil, err + } + previous, err := gonsole.Parse(env, previousEncryptionKeysSetting, nil, parseEncryptionKeys) + if err != nil { + return nil, err + } + if current == nil && previous != nil { + return nil, fmt.Errorf("%s is set but %s is not", + env.Key(previousEncryptionKeysSetting), env.Key(encryptionKeySetting)) + } + return encryption.New(current, previous) +} + +// parseEncryptionKey reads one key written as 64 hex characters, leaving the value out of every error. +func parseEncryptionKey(value string) ([]byte, error) { + key, err := hex.DecodeString(value) + if err != nil { + return nil, errors.New("must be written in hex, as openssl rand -hex 32 prints it") + } + if len(key) != encryption.KeySize { + return nil, fmt.Errorf("must hold %d bytes, got %d", encryption.KeySize, len(key)) + } + return key, nil +} + +// parseEncryptionKeys reads a comma list of keys, each written as 64 hex characters. +func parseEncryptionKeys(value string) ([][]byte, error) { + var keys [][]byte + for at, entry := range strings.Split(value, ",") { + key, err := parseEncryptionKey(strings.TrimSpace(entry)) + if err != nil { + return nil, fmt.Errorf("entry %d: %w", at+1, err) + } + keys = append(keys, key) + } + return keys, nil +} + +// wireSettingStores hands every plugin taking a settings store a store of its own over settings. +func wireSettingStores(registered []sdk.Plugin, settings *postgres.SettingStore) { + for _, plugin := range registered { + if consumer, ok := plugin.(sdk.SettingStoreConsumer); ok { + consumer.UseSettingStore(settings.ForPlugin(plugin.ID())) + } + } +} + +// wireEncrypters hands every plugin taking an encrypter one bound to the plugin over keys. +func wireEncrypters(registered []sdk.Plugin, keys *encryption.Keys) { + for _, plugin := range registered { + if consumer, ok := plugin.(sdk.EncrypterConsumer); ok { + consumer.UseEncrypter(pluginEncrypter{keys: keys, plugin: plugin.ID()}) + } + } +} + +// pluginEncrypter encrypts one plugin's own values for the workspace each call serves. +type pluginEncrypter struct { + keys *encryption.Keys + plugin string +} + +// Encrypt answers plaintext encrypted for the calling workspace, this plugin and purpose. +func (e pluginEncrypter) Encrypt(ctx context.Context, purpose string, plaintext []byte) ([]byte, error) { + ciphertext, err := e.keys.Encrypt(plaintext, e.binding(ctx, purpose)...) + if err != nil { + return nil, sdk.ErrNoEncryptionKey + } + return ciphertext, nil +} + +// Decrypt answers what Encrypt took, ErrCannotDecrypt for another workspace, plugin or purpose. +func (e pluginEncrypter) Decrypt(ctx context.Context, purpose string, ciphertext []byte) ([]byte, error) { + plaintext, err := e.keys.Decrypt(ciphertext, e.binding(ctx, purpose)...) + if errors.Is(err, encryption.ErrNoKey) { + return nil, sdk.ErrNoEncryptionKey + } + if err != nil { + return nil, fmt.Errorf("%w: %w", sdk.ErrCannotDecrypt, err) + } + return plaintext, nil +} + +// binding answers the associated data a value of the calling workspace, this plugin and purpose is bound to. +func (e pluginEncrypter) binding(ctx context.Context, purpose string) [][]byte { + tenant := sdk.TenantOrDefault(ctx) + return [][]byte{[]byte(pluginPurpose), tenant[:], []byte(e.plugin), []byte(purpose)} +} diff --git a/cmd/alphone/settingstorewiring_test.go b/cmd/alphone/settingstorewiring_test.go new file mode 100644 index 00000000..44099107 --- /dev/null +++ b/cmd/alphone/settingstorewiring_test.go @@ -0,0 +1,314 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "maps" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/alphone/internal/encryption" + "github.com/gopherium/alphone/sdk" +) + +// currentEncryptionKey is the encryption key the settings tests set, written as openssl rand -hex 32 prints one. +const currentEncryptionKey = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + +// olderEncryptionKey is the encryption key the settings tests replace, written the same way. +const olderEncryptionKey = "1f1e1d1c1b1a191817161514131211100f0e0d0c0b0a09080706050403020100" + +// settingTakingPlugin records the settings store and the encrypter the host hands it. +type settingTakingPlugin struct { + inertPlugin + settings sdk.SettingStore + encrypter sdk.Encrypter +} + +// UseSettingStore keeps the handed store. +func (p *settingTakingPlugin) UseSettingStore(store sdk.SettingStore) { + p.settings = store +} + +// UseEncrypter keeps the handed encrypter. +func (p *settingTakingPlugin) UseEncrypter(encrypter sdk.Encrypter) { + p.encrypter = encrypter +} + +// settingRead is what one read of a setting or one decryption answered. +type settingRead struct { + value string + found bool + err error +} + +// String answers the read as its value, whether it was found and its error. +func (r settingRead) String() string { + return fmt.Sprintf("%q found %v error %v", r.value, r.found, r.err) +} + +// readPlain answers what store reads as a plain value under key in the workspace ctx serves. +func readPlain(ctx context.Context, store sdk.SettingStore, key string) settingRead { + value, found, err := store.Lookup(ctx, key) + return settingRead{value: value, found: found, err: err} +} + +// readSecret answers what store opens as a secret under key in the workspace ctx serves. +func readSecret(ctx context.Context, store sdk.SettingStore, key string) settingRead { + value, found, err := store.LookupSecret(ctx, key) + return settingRead{value: value, found: found, err: err} +} + +// decrypted answers what one decryption answered, found when it opened. +func decrypted(plaintext []byte, err error) settingRead { + return settingRead{value: string(plaintext), found: plaintext != nil, err: err} +} + +// foundValue answers the read of value as found without an error. +func foundValue(value string) settingRead { + return settingRead{value: value, found: true} +} + +// composedSettingTakers composes the host over env with one setting taking plugin under each id beside a bystander. +func composedSettingTakers(t *testing.T, env map[string]string, ids ...string) []*settingTakingPlugin { + t.Helper() + taking := make([]*settingTakingPlugin, 0, len(ids)) + registered := []sdk.Plugin{inertPlugin{id: "bystander"}} + for _, id := range ids { + plugin := &settingTakingPlugin{inertPlugin: inertPlugin{id: id}} + taking, registered = append(taking, plugin), append(registered, plugin) + } + built, err := compose(t.Context(), composeConfigOf(t, env), func(sdk.Deps) ([]sdk.Plugin, error) { + return registered, nil + }) + if err != nil { + t.Fatalf("compose() error = %v, want nil", err) + } + abandonAtEnd(t, built) + for _, plugin := range taking { + wantBothSeams(t, plugin) + } + return taking +} + +// wantBothSeams fails the test unless the plugin received a settings store and an encrypter. +func wantBothSeams(t *testing.T, plugin *settingTakingPlugin) { + t.Helper() + if plugin.settings == nil || plugin.encrypter == nil { + t.Fatalf("the plugin %s received %v and %v, want a settings store and an encrypter", plugin.id, + plugin.settings, plugin.encrypter) + } +} + +// keyedSettings returns the settings that point at databaseURL with the keys env adds. +func keyedSettings(databaseURL string, env map[string]string) map[string]string { + settings := map[string]string{"ALPHONE_DATABASE_URL": databaseURL} + maps.Copy(settings, env) + return settings +} + +// currentKeyOnly returns the settings that set the current encryption key alone. +func currentKeyOnly() map[string]string { + return map[string]string{"ALPHONE_ENCRYPTION_KEY": currentEncryptionKey} +} + +// wantRead fails the test unless the read named what answered want. +func wantRead(t *testing.T, what string, got, want settingRead) { + t.Helper() + if got != want { + t.Errorf("%s = %+v, want %+v", what, got, want) + } +} + +// wantCannotOpen fails the test unless the read named what answered ErrCannotDecrypt and nothing beside it. +func wantCannotOpen(t *testing.T, what string, got settingRead) { + t.Helper() + if !errors.Is(got.err, sdk.ErrCannotDecrypt) || got != (settingRead{err: got.err}) { + t.Errorf("%s = %+v, want nothing beside %v", what, got, sdk.ErrCannotDecrypt) + } +} + +// settingsPool answers a pool on the database at databaseURL, closed once the test ends. +func settingsPool(t *testing.T, databaseURL string) *pgxpool.Pool { + t.Helper() + pool, err := pgxpool.New(t.Context(), databaseURL) + if err != nil { + t.Fatalf("connecting pool: %v", err) + } + t.Cleanup(pool.Close) + return pool +} + +// storeEncrypted stores encrypted under the full key of the default workspace, by hand. +func storeEncrypted(t *testing.T, pool *pgxpool.Pool, key string, encrypted []byte) { + t.Helper() + if _, err := pool.Exec(t.Context(), "INSERT INTO core.settings (tenant_id, key, encrypted) VALUES ($1, $2, $3)", + sdk.DefaultTenantID, key, encrypted); err != nil { + t.Fatalf("storing the encrypted value under %s: %v", key, err) + } +} + +// storedEncrypted answers the encrypted value the default workspace holds under the full key. +func storedEncrypted(t *testing.T, pool *pgxpool.Pool, key string) []byte { + t.Helper() + var encrypted []byte + if err := pool.QueryRow(t.Context(), "SELECT encrypted FROM core.settings WHERE tenant_id = $1 AND key = $2", + sdk.DefaultTenantID, key).Scan(&encrypted); err != nil { + t.Fatalf("reading the encrypted value under %s: %v", key, err) + } + return encrypted +} + +// currentKeys answers the encryption keys over the current key the settings tests set. +func currentKeys(t *testing.T) *encryption.Keys { + t.Helper() + raw, err := parseEncryptionKey(currentEncryptionKey) + if err != nil { + t.Fatalf("parseEncryptionKey() error = %v, want nil", err) + } + keys, err := encryption.New(raw, nil) + if err != nil { + t.Fatalf("encryption.New() error = %v, want nil", err) + } + return keys +} + +// wantMissingKey fails the test unless err is the missing key error naming the setting the server reads. +func wantMissingKey(t *testing.T, what string, err error) { + t.Helper() + if !errors.Is(err, sdk.ErrNoEncryptionKey) { + t.Errorf("%s error = %v, want %v", what, err, sdk.ErrNoEncryptionKey) + } + if want := settingsEnv(nil).Key(encryptionKeySetting); !strings.Contains(fmt.Sprint(err), want) { + t.Errorf("%s error = %v, want it to name %s", what, err, want) + } +} + +func TestComposeHandsEachSettingTakingPluginAStoreOfItsOwn(t *testing.T) { + t.Parallel() + + taking := composedSettingTakers(t, keyedSettings(migratedPostgres(t), currentKeyOnly()), "email", "calendar") + email, calendar := taking[0].settings, taking[1].settings + + if err := email.Save(t.Context(), map[string]string{"imap.host": "imap.example.com"}); err != nil { + t.Fatalf("Save() through the email store error = %v, want nil", err) + } + if err := email.SaveSecrets(t.Context(), map[string]string{"imap.password": "correct horse battery"}); err != nil { + t.Fatalf("SaveSecrets() through the email store error = %v, want nil", err) + } + + wantRead(t, "Lookup() through the calendar store", readPlain(t.Context(), calendar, "imap.host"), settingRead{}) + wantRead(t, "Lookup() through the email store", readPlain(t.Context(), email, "imap.host"), + foundValue("imap.example.com")) + wantRead(t, "LookupSecret() through the email store", readSecret(t.Context(), email, "imap.password"), + foundValue("correct horse battery")) +} + +func TestComposeHandsEachPluginAnEncrypterBoundToItsWorkspaceAndPurpose(t *testing.T) { + t.Parallel() + + taking := composedSettingTakers(t, keyedSettings(unreachableDatabaseURL, currentKeyOnly()), "email", "calendar") + email, calendar := taking[0].encrypter, taking[1].encrypter + acme := sdk.WithTenant(t.Context(), uuid.Must(uuid.NewV7())) + ciphertext, err := email.Encrypt(acme, "imap.password", []byte("correct horse battery")) + if err != nil { + t.Fatalf("Encrypt() error = %v, want nil", err) + } + + wantRead(t, "Decrypt() in the same workspace", decrypted(email.Decrypt(acme, "imap.password", ciphertext)), + foundValue("correct horse battery")) + for name, got := range map[string]settingRead{ + "another workspace": decrypted(email.Decrypt(t.Context(), "imap.password", ciphertext)), + "another purpose": decrypted(email.Decrypt(acme, "smtp.password", ciphertext)), + "another plugin": decrypted(calendar.Decrypt(acme, "imap.password", ciphertext)), + } { + wantCannotOpen(t, "Decrypt() for "+name, got) + } +} + +func TestAPluginValueIsBoundToThePluginLabelItsWorkspaceItsPluginAndItsPurpose(t *testing.T) { + t.Parallel() + + email := composedSettingTakers(t, keyedSettings(unreachableDatabaseURL, currentKeyOnly()), "email")[0] + acme := uuid.Must(uuid.NewV7()) + + ciphertext, err := email.encrypter.Encrypt(sdk.WithTenant(t.Context(), acme), "imap.password", + []byte("correct horse battery")) + if err != nil { + t.Fatalf("Encrypt() error = %v, want nil", err) + } + + wantRead(t, "Decrypt() with the plugin binding", + decrypted(currentKeys(t).Decrypt(ciphertext, []byte("plugin"), acme[:], []byte("email"), []byte("imap.password"))), + foundValue("correct horse battery")) +} + +func TestAPluginValueAndAStoredSecretDoNotOpenAsEachOther(t *testing.T) { + t.Parallel() + + databaseURL := migratedPostgres(t) + email := composedSettingTakers(t, keyedSettings(databaseURL, currentKeyOnly()), "email")[0] + pool := settingsPool(t, databaseURL) + pluginValue, err := email.encrypter.Encrypt(t.Context(), "imap.password", []byte("correct horse battery")) + if err != nil { + t.Fatalf("Encrypt() error = %v, want nil", err) + } + secrets := map[string]string{"smtp.password": "correct horse battery"} + if err := email.settings.SaveSecrets(t.Context(), secrets); err != nil { + t.Fatalf("SaveSecrets() error = %v, want nil", err) + } + + storeEncrypted(t, pool, "plugin.email.imap.password", pluginValue) + storedSecret := storedEncrypted(t, pool, "plugin.email.smtp.password") + + wantCannotOpen(t, "LookupSecret() of a plugin value stored as a setting", + readSecret(t.Context(), email.settings, "imap.password")) + wantCannotOpen(t, "Decrypt() of a stored secret", + decrypted(email.encrypter.Decrypt(t.Context(), "smtp.password", storedSecret))) +} + +func TestWithoutAnEncryptionKeySecretsNameTheMissingVariable(t *testing.T) { + t.Parallel() + + taking := composedSettingTakers(t, keyedSettings(migratedPostgres(t), nil), "email")[0] + + wantMissingKey(t, "SaveSecrets()", + taking.settings.SaveSecrets(t.Context(), map[string]string{"imap.password": "correct horse battery"})) + _, err := taking.encrypter.Encrypt(t.Context(), "imap.password", []byte("correct horse battery")) + wantMissingKey(t, "Encrypt()", err) + _, err = taking.encrypter.Decrypt(t.Context(), "imap.password", []byte{1}) + wantMissingKey(t, "Decrypt()", err) + if err := taking.settings.Save(t.Context(), map[string]string{"imap.host": "imap.example.com"}); err != nil { + t.Errorf("Save() of a plain value error = %v, want it stored without a key", err) + } +} + +func TestAReplacedEncryptionKeyStillOpensTheOlderSecrets(t *testing.T) { + t.Parallel() + + databaseURL := migratedPostgres(t) + older := composedSettingTakers(t, keyedSettings(databaseURL, map[string]string{ + "ALPHONE_ENCRYPTION_KEY": olderEncryptionKey, + }), "email")[0] + if err := older.settings.SaveSecrets(t.Context(), map[string]string{"smtp.password": "older secret"}); err != nil { + t.Fatalf("SaveSecrets() under the older key error = %v, want nil", err) + } + ciphertext, err := older.encrypter.Encrypt(t.Context(), "access_token", []byte("older token")) + if err != nil { + t.Fatalf("Encrypt() under the older key error = %v, want nil", err) + } + + rotated := composedSettingTakers(t, keyedSettings(databaseURL, map[string]string{ + "ALPHONE_ENCRYPTION_KEY": currentEncryptionKey, "ALPHONE_ENCRYPTION_PREVIOUS_KEYS": olderEncryptionKey, + }), "email")[0] + + wantRead(t, "LookupSecret() after the rotation", readSecret(t.Context(), rotated.settings, "smtp.password"), + foundValue("older secret")) + wantRead(t, "Decrypt() after the rotation", + decrypted(rotated.encrypter.Decrypt(t.Context(), "access_token", ciphertext)), foundValue("older token")) +} diff --git a/cmd/alphone/workspacesettings_feature_test.go b/cmd/alphone/workspacesettings_feature_test.go new file mode 100644 index 00000000..828ec9d7 --- /dev/null +++ b/cmd/alphone/workspacesettings_feature_test.go @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: Elastic-2.0 + +package main + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/cucumber/godog" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/gopherium/framework/gonsole/testkit" + + "github.com/gopherium/alphone/internal/role" + "github.com/gopherium/alphone/sdk" +) + +// workspaceSettingsFeature is the feature the settings stores answer to. +const workspaceSettingsFeature = "../../test/features/features/workspace-settings.feature" + +// settingsPluginID names the mail plugin the workspace settings scenarios run. +const settingsPluginID = "email" + +// calendarPluginID names the calendar plugin the workspace settings scenarios run beside the mail plugin. +const calendarPluginID = "calendar" + +// mailboxHostKey is the key the plugin keeps the mailbox host under. +const mailboxHostKey = "imap.host" + +// mailboxPasswordKey is the key the plugin keeps the mailbox password under. +const mailboxPasswordKey = "imap.password" + +// copyStoredSecret stores the encrypted value of one key of one workspace under another workspace, by hand. +const copyStoredSecret = `INSERT INTO core.settings (tenant_id, key, encrypted) + SELECT $1, key, encrypted FROM core.settings WHERE tenant_id = $2 AND key = $3` + +// storeHandingOutStores is a settings store that hands out the store of any plugin. +type storeHandingOutStores interface { + ForPlugin(id string) sdk.SettingStore +} + +// settingsScenario is what one workspace settings scenario keeps between its steps. +type settingsScenario struct { + t *testing.T + env map[string]string + pool *pgxpool.Pool + workspaces map[string]uuid.UUID + mail *settingTakingPlugin + calendar *settingTakingPlugin + reached sdk.SettingStore + built composed + composed bool +} + +// initializeWorkspaceSettings returns the binding of the workspace settings steps, t holding their databases. +func initializeWorkspaceSettings(t *testing.T) func(*godog.ScenarioContext) { + return func(sc *godog.ScenarioContext) { + s := &settingsScenario{t: t, env: map[string]string{}, workspaces: map[string]uuid.UUID{}} + sc.Given(`^the database is migrated$`, s.migrateTheDatabase) + sc.Given(`^the server holds an encryption key$`, s.holdAnEncryptionKey) + sc.Given(`^the workspace "([^"]*)" exists$`, s.createWorkspace) + sc.Given(`^AlphOne runs a mail plugin and a calendar plugin that keep settings$`, s.runPluginsThatKeepSettings) + sc.When(`^the workspace "([^"]*)" saves the mailbox host "([^"]*)"$`, s.saveHost) + sc.Then(`^the workspace "([^"]*)" reads the mailbox host "([^"]*)"$`, s.readsHost) + sc.Given(`^the workspace "([^"]*)" saves the mailbox password "([^"]*)"$`, s.savePassword) + sc.When(`^the mailbox password of "([^"]*)" is copied to "([^"]*)"$`, s.copyPassword) + sc.Then(`^the workspace "([^"]*)" cannot open its mailbox password$`, s.cannotOpenPassword) + sc.Then(`^the workspace "([^"]*)" still opens its mailbox password as "([^"]*)"$`, s.opensPassword) + sc.When(`^the calendar plugin asks its store for the store of the mail plugin$`, s.askForTheMailStore) + sc.Then(`^the calendar plugin gets no store of the mail plugin$`, s.getsNoMailStore) + sc.Then(`^the calendar plugin reads no mailbox password in "([^"]*)"$`, s.readsNoPasswordIn) + sc.After(func(ctx context.Context, _ *godog.Scenario, _ error) (context.Context, error) { + return ctx, s.stop(ctx) + }) + } +} + +// migrateTheDatabase points the settings at a fresh database every schema step migrated. +func (s *settingsScenario) migrateTheDatabase(ctx context.Context) error { + databaseURL := barePostgres(s.t) + if err := migrate(ctx, databaseURL); err != nil { + return err + } + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + return err + } + s.env["ALPHONE_DATABASE_URL"], s.pool = databaseURL, pool + return nil +} + +// holdAnEncryptionKey sets the key the server encrypts secrets with. +func (s *settingsScenario) holdAnEncryptionKey() { + s.env["ALPHONE_ENCRYPTION_KEY"] = currentEncryptionKey +} + +// createWorkspace stores the workspace called name and keeps its id. +func (s *settingsScenario) createWorkspace(ctx context.Context, name string) error { + id := uuid.Must(uuid.NewV7()) + if _, err := s.pool.Exec(ctx, "INSERT INTO core.tenants (id, name) VALUES ($1, $2)", id, name); err != nil { + return fmt.Errorf("storing the workspace %s: %w", name, err) + } + s.workspaces[name] = id + return nil +} + +// runPluginsThatKeepSettings composes the host over the settings with a mail and a calendar plugin taking a store. +func (s *settingsScenario) runPluginsThatKeepSettings(ctx context.Context) error { + settings, err := loadRunConfig(testkit.Getenv(s.env)) + if err != nil { + return err + } + s.mail = &settingTakingPlugin{inertPlugin: inertPlugin{id: settingsPluginID}} + s.calendar = &settingTakingPlugin{inertPlugin: inertPlugin{id: calendarPluginID}} + s.built, err = compose(ctx, composeConfig{ + composeSettings: settings.composeSettings, + databaseURL: settings.databaseURL, + getenv: testkit.Getenv(s.env), + roles: role.NewRegistry(), + logger: discardLogger(), + }, func(sdk.Deps) ([]sdk.Plugin, error) { return []sdk.Plugin{s.mail, s.calendar}, nil }) + s.composed = true + if err != nil { + return err + } + for _, plugin := range []*settingTakingPlugin{s.mail, s.calendar} { + if plugin.settings == nil { + return fmt.Errorf("the plugin %s received no settings store", plugin.id) + } + } + return nil +} + +// in returns ctx serving the workspace called name. +func (s *settingsScenario) in(ctx context.Context, name string) context.Context { + return sdk.WithTenant(ctx, s.workspaces[name]) +} + +// saveHost saves the mailbox host of the workspace called name. +func (s *settingsScenario) saveHost(ctx context.Context, name, host string) error { + return s.mail.settings.Save(s.in(ctx, name), map[string]string{mailboxHostKey: host}) +} + +// readsHost fails unless the workspace called name reads host as its mailbox host. +func (s *settingsScenario) readsHost(ctx context.Context, name, host string) error { + if got := readPlain(s.in(ctx, name), s.mail.settings, mailboxHostKey); got != foundValue(host) { + return fmt.Errorf("the mailbox host of %s reads %+v, want %q", name, got, host) + } + return nil +} + +// savePassword saves the mailbox password of the workspace called name as a secret. +func (s *settingsScenario) savePassword(ctx context.Context, name, password string) error { + return s.mail.settings.SaveSecrets(s.in(ctx, name), map[string]string{mailboxPasswordKey: password}) +} + +// copyPassword stores what the workspace called from holds encrypted as its mailbox password in the one called to. +func (s *settingsScenario) copyPassword(ctx context.Context, from, to string) error { + stored := "plugin." + settingsPluginID + "." + mailboxPasswordKey + if _, err := s.pool.Exec(ctx, copyStoredSecret, s.workspaces[to], s.workspaces[from], stored); err != nil { + return fmt.Errorf("copying the mailbox password from %s to %s: %w", from, to, err) + } + return nil +} + +// cannotOpenPassword fails unless the workspace called name finds its mailbox password and cannot decrypt it. +func (s *settingsScenario) cannotOpenPassword(ctx context.Context, name string) error { + got := readSecret(s.in(ctx, name), s.mail.settings, mailboxPasswordKey) + if !errors.Is(got.err, sdk.ErrCannotDecrypt) || got.found { + return fmt.Errorf("the mailbox password of %s reads %+v, want nothing beside %v", name, got, + sdk.ErrCannotDecrypt) + } + return nil +} + +// opensPassword fails unless the workspace called name opens password as its mailbox password. +func (s *settingsScenario) opensPassword(ctx context.Context, name, password string) error { + if got := readSecret(s.in(ctx, name), s.mail.settings, mailboxPasswordKey); got != foundValue(password) { + return fmt.Errorf("the mailbox password of %s reads %+v, want %q", name, got, password) + } + return nil +} + +// askForTheMailStore keeps the store of the mail plugin that the calendar plugin's store hands out, if it hands any. +func (s *settingsScenario) askForTheMailStore() { + if handing, ok := s.calendar.settings.(storeHandingOutStores); ok { + s.reached = handing.ForPlugin(settingsPluginID) + } +} + +// getsNoMailStore fails when the calendar plugin reached the store of the mail plugin through its own store. +func (s *settingsScenario) getsNoMailStore() error { + if s.reached != nil { + return errors.New("the calendar plugin reached the store of the mail plugin, want its own store alone") + } + return nil +} + +// readsNoPasswordIn fails unless the calendar plugin reads no mailbox password in the workspace called name. +func (s *settingsScenario) readsNoPasswordIn(ctx context.Context, name string) error { + if got := readSecret(s.in(ctx, name), s.calendar.settings, mailboxPasswordKey); got != (settingRead{}) { + return fmt.Errorf("the calendar plugin reads the mailbox password of %s as %+v, want it unset", name, got) + } + return nil +} + +// stop stops what the scenario composed and closes its pool. +func (s *settingsScenario) stop(ctx context.Context) error { + var err error + if s.composed { + err = abandon(ctx, s.built, pluginStopGrace) + } + if s.pool != nil { + s.pool.Close() + } + return err +} + +func TestWorkspaceSettings(t *testing.T) { + runFeature(t, workspaceSettingsFeature, initializeWorkspaceSettings(t)) +} From ef0acd995994eac92f6468cce759cc1114e02fb0 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:35 +0100 Subject: [PATCH 09/10] docs(self-hosting): describe the encryption key, how to replace it and why to back it up --- .../docs/self-hosting/configuration.md | 41 ++++++++++++++++++- docs/src/content/docs/self-hosting/install.md | 8 ++++ .../docs/self-hosting/updates-and-backups.md | 16 ++++++++ 3 files changed, 64 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/self-hosting/configuration.md b/docs/src/content/docs/self-hosting/configuration.md index a5f343bc..ad1886fc 100644 --- a/docs/src/content/docs/self-hosting/configuration.md +++ b/docs/src/content/docs/self-hosting/configuration.md @@ -19,7 +19,8 @@ When a variable holds a value AlphOne refuses: the commands read. - A refused value under [Mail](#mail), [Workspaces](#workspaces), [Contacts](#contacts), [Webhooks](#webhooks), [Files](#files), - [Fields plugin](#fields-plugin) or [WhatsApp plugin](#whatsapp-plugin), + [Encryption](#encryption), [Fields plugin](#fields-plugin) or + [WhatsApp plugin](#whatsapp-plugin), in `ALPHONE_PUBLIC_URL`, `ALPHONE_GRAPH_PAGE_SIZE` or `ALPHONE_GRAPH_PAGE_CAP`, or in `ALPHONE_SHUTDOWN_STOP_GRACE`, also stops the account commands that take `-as`, before they change @@ -296,6 +297,44 @@ Once the migrations ran, `serve` checks that it can reach the files and stops when it cannot, with an error that names the driver, such as `storage: the s3 bucket is not there, check the storage settings`. +## Encryption + +Plugins keep some settings of each workspace as secrets, such as the +password of a mailbox, see [Settings](/extending/settings/). AlphOne +encrypts each secret with AES-256-GCM under the key you set here, and +ties it to its workspace and its setting, so a copy pasted anywhere else +does not open. The key lives only in the environment, never in the +database. + +| Variable | Purpose | +| --- | --- | +| `ALPHONE_ENCRYPTION_KEY` | The key that encrypts every new secret, 32 bytes written as 64 hex characters. Make it once with `openssl rand -hex 32`. Unset, AlphOne still starts and keeps plain settings, and a plugin that saves or reads a secret gets an error that names this variable. | +| `ALPHONE_ENCRYPTION_PREVIOUS_KEYS` | The keys that still open the secrets saved before the current key, as a comma list of keys written the same way. Unset, only the current key opens them. It needs `ALPHONE_ENCRYPTION_KEY` set too. | + +A key that is not hex or does not hold 32 bytes, or previous keys +without a current key, stops `serve` and the other commands listed at +the top of this page. The error names the variable and never shows the +key, such as `ALPHONE_ENCRYPTION_KEY: must hold 32 bytes, got 16`. + +Keep a copy of every key off the server, see +[Backup scenario](/self-hosting/updates-and-backups/#backup-scenario). +Without the key no stored secret opens again. + +### Replacing the key + +1. Make a new key with `openssl rand -hex 32`. +2. Put the old key at the front of `ALPHONE_ENCRYPTION_PREVIOUS_KEYS`, + and the new key in `ALPHONE_ENCRYPTION_KEY`. +3. Restart AlphOne. New secrets use the new key, and the older ones + still open. +4. Keep every previous key. No command encrypts the older secrets again + under the new key yet, so a key you drop takes the secrets it + encrypted with it. + +The order of the previous keys does not matter. Each encrypted value +carries an id that the key itself gives, and AlphOne tries every key +with that id. + ## Fields plugin | Variable | Purpose | diff --git a/docs/src/content/docs/self-hosting/install.md b/docs/src/content/docs/self-hosting/install.md index 235c4402..569dac81 100644 --- a/docs/src/content/docs/self-hosting/install.md +++ b/docs/src/content/docs/self-hosting/install.md @@ -99,6 +99,7 @@ Create `.env` next to `compose.yaml`, with permissions `600`: ```ini POSTGRES_PASSWORD= ALPHONE_TRUSTED_PROXIES= +ALPHONE_ENCRYPTION_KEY= # Only needed once you connect WhatsApp. See the Meta setup guide. ALPHONE_WHATSAPP_VERIFY_TOKEN= @@ -115,6 +116,13 @@ Make `POSTGRES_PASSWORD` letters and digits only, for example the output of database address, where a sign such as `@` ends the password early and stops AlphOne at start. +`ALPHONE_ENCRYPTION_KEY` encrypts the passwords and tokens plugins keep +for each workspace. Make it once with `openssl rand -hex 32` and keep a +copy off the server, for example in a password manager, because without +it no stored secret opens again. Replace it only the way +[Encryption](/self-hosting/configuration/#encryption) explains, or the +secrets saved under it stop opening. + `ALPHONE_TRUSTED_PROXIES` deserves a moment of attention. The login rate limiter counts failed attempts per client address, and behind a proxy the real address arrives in the `X-Forwarded-For` header. AlphOne only trusts diff --git a/docs/src/content/docs/self-hosting/updates-and-backups.md b/docs/src/content/docs/self-hosting/updates-and-backups.md index b8c670a5..acd0349c 100644 --- a/docs/src/content/docs/self-hosting/updates-and-backups.md +++ b/docs/src/content/docs/self-hosting/updates-and-backups.md @@ -115,6 +115,10 @@ server, because a watcher on `:latest` applies it on its own. shows for you to pass on starts with it, so make sure it still names the address people reach AlphOne at. See [Core](/self-hosting/configuration/#core). +- AlphOne reads `ALPHONE_ENCRYPTION_KEY`, the key that encrypts the + secrets plugins keep for each workspace. It stays optional until a + plugin stores a secret, see + [Encryption](/self-hosting/configuration/#encryption). - AlphOne keeps a `storage` schema in its database for the files plugins keep, and checks at start that it can reach them. With the default settings it creates the schema on its first start, and you set @@ -264,6 +268,12 @@ Copy the `backups/` directory somewhere off the server on your own schedule. A backup that lives only next to the database it protects is half a backup. +Back up `ALPHONE_ENCRYPTION_KEY` and `ALPHONE_ENCRYPTION_PREVIOUS_KEYS` +off the server as well, for example in a password manager. The dumps +hold the secrets plugins keep only in encrypted form, so a restore +without those keys opens none of them. Keep the keys apart from the +dumps, so that whoever gets a dump does not get the keys with it. + ## Backing up files With the `postgres` driver, the default, the files plugins keep live in @@ -303,6 +313,12 @@ gunzip -c backups/alphone-.sql.gz | \ docker compose start alphone ``` +A restored database needs the same encryption keys it was saved under. +Set `ALPHONE_ENCRYPTION_KEY` and `ALPHONE_ENCRYPTION_PREVIOUS_KEYS` to +the keys in use when the dump was taken, or with a newer key, put every +older key in the previous list. A plugin that reads a secret no key +opens gets an error instead of the secret. + A dump can also go onto a new PostgreSQL server, such as a fresh container for a new PostgreSQL major version. The new server numbers its transactions from the start again. A plugin that follows the addresses contacts gain, see From 976dbbd4c2eaccfc7d065da4c6c8884f2a3fe449 Mon Sep 17 00:00:00 2001 From: SirLouen Date: Tue, 27 Oct 2026 22:59:35 +0100 Subject: [PATCH 10/10] docs(extending): add a settings page for plugin authors --- docs/astro.config.mjs | 1 + docs/src/content/docs/extending/settings.md | 205 ++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 docs/src/content/docs/extending/settings.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 3a2e78cd..50821639 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -68,6 +68,7 @@ export default defineConfig({ { slug: 'extending/events' }, { slug: 'extending/contacts' }, { slug: 'extending/files' }, + { slug: 'extending/settings' }, ], }, { diff --git a/docs/src/content/docs/extending/settings.md b/docs/src/content/docs/extending/settings.md new file mode 100644 index 00000000..e6edcbf9 --- /dev/null +++ b/docs/src/content/docs/extending/settings.md @@ -0,0 +1,205 @@ +--- +title: Settings +description: Keep the settings each workspace chooses for your plugin, such as a mailbox address and its password, with the secrets encrypted. +--- + +A plugin that needs the settings each workspace chooses, such as the +address and the password of a mailbox, gets a settings store from +AlphOne. It saves several values in one write and reads each one back. +The store keeps every value under the workspace and the plugin that +saved it, so no other workspace and no other plugin ever reaches it. + +If you know WordPress, this is `get_option` and `update_option`, kept +apart for each workspace. + +## Take a store + +Implement `sdk.SettingStoreConsumer`. AlphOne calls its one method after +every plugin has registered and before any plugin starts: + +```go +var _ sdk.SettingStoreConsumer = (*Plugin)(nil) + +// UseSettingStore keeps the settings store AlphOne hands the plugin. +func (p *Plugin) UseSettingStore(store sdk.SettingStore) { + p.settings = store +} +``` + +Every plugin that implements it gets a store of its own. The `var _` +line turns a misspelled method into a build error. + +## Keys + +A key is made of parts joined by dots, and each part holds lower case +letters, digits, `_` and `-`, such as `imap.host` or `imap.password`. +AlphOne keeps your keys under `plugin.` and your plugin id, so the +`imap.host` of the email plugin is stored as `plugin.email.imap.host`. +You never write that part yourself, and your plugin cannot name a key +of another plugin or of AlphOne itself. + +A key in another form, such as `Imap.Host` or `imap..host`, fails the +whole write, and nothing of it is stored. Keys come from your code, so a +test of your plugin finds a bad one before anyone else does. + +A read matches the key exactly. `Imap.Host`, or `imap.host` with a space +after it, reads as unset. + +## Plain values + +`Save` stores every value of the map or none of them: + +```go +err := p.settings.Save(ctx, map[string]string{ + "imap.host": "imap.example.com", + "imap.port": "993", +}) +``` + +An empty map stores nothing and answers no error. + +`Lookup` answers the value and whether the key holds one: + +```go +host, found, err := p.settings.Lookup(ctx, "imap.host") +``` + +`found` tells an unset key from one stored as empty. An empty value is a +real value. It reads back found and empty. Nothing deletes a key, so +save an empty value to clear one. + +`LookupPrefix` answers every plain value whose key starts with a prefix, +such as every key under `imap.`, and leaves the secrets out. It reads the +prefix as plain text, so `_` and `%` match only themselves. + +## Secrets + +Passwords and tokens go through the secret methods: + +```go +err := p.settings.SaveSecrets(ctx, map[string]string{"imap.password": password}) + +password, found, err := p.settings.LookupSecret(ctx, "imap.password") + +set, err := p.settings.HasSecret(ctx, "imap.password") +``` + +`SaveSecrets` encrypts every secret with the key the operator sets in +`ALPHONE_ENCRYPTION_KEY`, see +[Encryption](/self-hosting/configuration/#encryption), and stores all of +them or none. `HasSecret` answers whether a key holds a secret that is +not empty, so a screen can show that a password is set without ever +sending it to the browser. An empty secret reads back found and empty, +and `HasSecret` answers false for it. `HasSecret` decrypts the secret to +tell, so like `LookupSecret` it answers `sdk.ErrNoEncryptionKey` without +a key, `sdk.ErrCannotDecrypt` when no key opens the secret, and +`sdk.ErrPlainSetting` for a key that holds a plain value. + +A plain read never shows a secret. `Lookup` of a key that holds a secret +answers `sdk.ErrSecretSetting`, and `LookupSecret` of a plain value +answers `sdk.ErrPlainSetting`. A write of the other kind replaces what +the key held, so a value can move from plain to secret. + +Without a key the server still starts, and plain values work. A secret +write or read then answers `sdk.ErrNoEncryptionKey`, whose text names +`ALPHONE_ENCRYPTION_KEY`. Show it to the person, so they know what to +ask the operator for. + +The database cannot search or sort an encrypted value, because it holds +only bytes it cannot read. +[Laravel warns about the same thing](https://laravel.com/docs/13.x/eloquent-mutators#encrypted-casting) +for its encrypted columns. Keep anything you need to search as a plain +value. + +## Every call serves one workspace + +Each call works in the workspace `ctx` carries. AlphOne puts it on `ctx` +for every signed in request. Work your plugin starts on its own, such as +a background sync, sets it with `sdk.WithTenant`. A `ctx` that carries no +workspace works in the default one. + +A value saved in one workspace reads as unset in every other workspace. + +## Which value wins + +A value the workspace stored always wins, even an empty one. The +environment file only fills in the default workspace, and only while +that workspace stored nothing under the key. Every other workspace gets +your plugin's own default or nothing. The WhatsApp plugin already reads +its credentials this way. For the host of a mailbox, with `p.seed` +holding what the plugin read from the environment at `Register`, it +looks like this: + +```go +host, found, err := p.settings.Lookup(ctx, "imap.host") +if err != nil { + return err +} +if !found && sdk.TenantOrDefault(ctx) == sdk.DefaultTenantID { + host = p.seed.imapHost +} +``` + +Nothing copies a value from the environment file into the store. Once a +workspace saves a value, its line in the environment file can go. + +For settings like these the environment file is a stopgap, and it +shrinks over time. It keeps what AlphOne needs before the database +opens, such as the database address, what holds for a whole server +process, such as the page bounds, and the encryption keys themselves, +because a key stored next to the data it protects protects nothing. + +## Values in your own tables + +A plugin that keeps a secret on a row of its own table, such as the +token of a mailbox, encrypts it the same way through +`sdk.EncrypterConsumer`: + +```go +var _ sdk.EncrypterConsumer = (*Plugin)(nil) + +// UseEncrypter keeps the encrypter AlphOne hands the plugin. +func (p *Plugin) UseEncrypter(encrypter sdk.Encrypter) { + p.encrypter = encrypter +} +``` + +Encrypt before you store the row, and decrypt after you read it: + +```go +stored, err := p.encrypter.Encrypt(ctx, "refresh_token", []byte(token)) + +token, err := p.encrypter.Decrypt(ctx, "refresh_token", stored) +``` + +The purpose names what the value is. Use one purpose for each kind of +value. `Decrypt` answers `sdk.ErrCannotDecrypt` for a value encrypted in +another workspace, by another plugin or for another purpose. Without a +key both answer `sdk.ErrNoEncryptionKey`. + +## Why a copied value does not open + +Every secret is encrypted together with where it belongs: the +workspace, the plugin, and the key or the purpose. This is called +associated data, and the value only opens with the same associated +data. [Google's Tink](https://developers.google.com/tink/aead) gives the +reason in plain words: tying a value to its user "prevents an attacker +from moving medical history from one user to another". + +Here it means that someone who can write to the database cannot copy +one workspace's mailbox password into another workspace, or under +another key, and have it open there. The read answers +`sdk.ErrCannotDecrypt` instead. + +## What other frameworks call it + +| | The stored values | The encryption | Replacing the key | +| --- | --- | --- | --- | +| AlphOne | `sdk.SettingStore` | `sdk.Encrypter`, `ALPHONE_ENCRYPTION_KEY` | `ALPHONE_ENCRYPTION_PREVIOUS_KEYS` | +| WordPress | `wp_options`, `get_option` and `update_option`, shown under Settings | none, a password is stored in plain text | none | +| Laravel | packages such as spatie/laravel-settings | `Crypt` and the `Encrypter` contract, `APP_KEY` | `APP_PREVIOUS_KEYS` | +| Rails | packages such as rails-settings-cached | Active Record Encryption, `encrypts` | a list of keys, the last one encrypts | +| Django | packages such as django-constance | none in Django itself | `SECRET_KEY_FALLBACKS`, for signing only | + +None of the others ties an encrypted value to where it belongs, so a +copied value opens anywhere their key does.