feat: expose scoped secret store API
This commit is contained in:
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/verstak/verstak-desktop/internal/core/permissions"
|
||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||
coresecrets "github.com/verstak/verstak-desktop/internal/core/secrets"
|
||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||
@@ -61,6 +62,7 @@ type App struct {
|
||||
workbench *coreworkbench.Router
|
||||
workspace *workspace.Manager
|
||||
syncSvc *syncsvc.Service
|
||||
secretsSession *coresecrets.VaultSession
|
||||
fileWatcher *filewatcher.Service
|
||||
debug bool
|
||||
activityEvents map[string]bool
|
||||
@@ -712,6 +714,7 @@ func (a *App) CreateVault(path string) error {
|
||||
if err := a.vault.CreateVault(path); err != nil {
|
||||
return err
|
||||
}
|
||||
a.secretsSession = nil
|
||||
a.startFileWatcherForOpenVault()
|
||||
return nil
|
||||
}
|
||||
@@ -724,6 +727,7 @@ func (a *App) OpenVault(path string) error {
|
||||
if err := a.vault.OpenVault(path); err != nil {
|
||||
return err
|
||||
}
|
||||
a.secretsSession = nil
|
||||
a.startFileWatcherForOpenVault()
|
||||
return nil
|
||||
}
|
||||
@@ -737,6 +741,7 @@ func (a *App) CloseVault() error {
|
||||
a.fileWatcher.Stop()
|
||||
}
|
||||
a.vault.CloseVault()
|
||||
a.secretsSession = nil
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1277,6 +1282,166 @@ func (a *App) EditWorkbenchResource(pluginID string, rawRequest map[string]inter
|
||||
return a.OpenWorkbenchResource(pluginID, rawRequest)
|
||||
}
|
||||
|
||||
// ─── Secrets API ────────────────────────────────────────────
|
||||
|
||||
func (a *App) requirePluginSecretsAccess(pluginID string, write bool) error {
|
||||
if _, err := a.requirePluginAccess(pluginID, "secrets.read"); err != nil {
|
||||
return err
|
||||
}
|
||||
if write {
|
||||
if _, err := a.requirePluginAccess(pluginID, "secrets.write"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureSecretsSession() (*coresecrets.VaultSession, error) {
|
||||
if err := a.requireVault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.secretsSession == nil {
|
||||
a.secretsSession = coresecrets.NewVaultSession(filepath.Join(a.vaultPath(), ".verstak", "secrets"))
|
||||
}
|
||||
return a.secretsSession, nil
|
||||
}
|
||||
|
||||
func (a *App) requireUnlockedSecretStore() (*coresecrets.Store, error) {
|
||||
session, err := a.ensureSecretsSession()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return session.Store()
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsStatus(pluginID string) (map[string]interface{}, string) {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, false); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
session, err := a.ensureSecretsSession()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"unlocked": session.Unlocked(),
|
||||
}, ""
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsUnlock(pluginID, masterPassword string) string {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, false); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
session, err := a.ensureSecretsSession()
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
if _, err := session.Unlock(masterPassword); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsList(pluginID string) ([]map[string]interface{}, string) {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, false); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
store, err := a.requireUnlockedSecretStore()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
records, err := store.ListRecords()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
result := make([]map[string]interface{}, 0, len(records))
|
||||
for _, record := range records {
|
||||
result = append(result, secretRecordMap(record, false))
|
||||
}
|
||||
return result, ""
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsRead(pluginID, secretID string) (map[string]interface{}, string) {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, false); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
store, err := a.requireUnlockedSecretStore()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
record, err := store.ReadRecord(secretID)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return secretRecordMap(record, true), ""
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsWrite(pluginID string, rawRecord map[string]interface{}) (map[string]interface{}, string) {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, true); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
store, err := a.requireUnlockedSecretStore()
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
record, err := decodeSecretRecord(rawRecord)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
if err := store.WriteRecord(record); err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
written, err := store.ReadRecord(record.ID)
|
||||
if err != nil {
|
||||
return nil, err.Error()
|
||||
}
|
||||
return secretRecordMap(written, false), ""
|
||||
}
|
||||
|
||||
func (a *App) PluginSecretsCopyLink(pluginID, secretID string) (string, string) {
|
||||
if err := a.requirePluginSecretsAccess(pluginID, false); err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
store, err := a.requireUnlockedSecretStore()
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
record, err := store.ReadRecord(secretID)
|
||||
if err != nil {
|
||||
return "", err.Error()
|
||||
}
|
||||
title := strings.TrimSpace(record.Title)
|
||||
if title == "" {
|
||||
title = record.ID
|
||||
}
|
||||
return fmt.Sprintf("[%s](verstak-secret://%s)", title, url.PathEscape(record.ID)), ""
|
||||
}
|
||||
|
||||
func decodeSecretRecord(raw map[string]interface{}) (coresecrets.SecretRecord, error) {
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return coresecrets.SecretRecord{}, err
|
||||
}
|
||||
var record coresecrets.SecretRecord
|
||||
if err := json.Unmarshal(data, &record); err != nil {
|
||||
return coresecrets.SecretRecord{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func secretRecordMap(record coresecrets.SecretRecord, includeValue bool) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"id": record.ID,
|
||||
"title": record.Title,
|
||||
"scope": map[string]interface{}{"kind": record.Scope.Kind, "workspaceRootPath": record.Scope.WorkspaceRootPath},
|
||||
"username": record.Username,
|
||||
"updatedAt": record.UpdatedAt,
|
||||
}
|
||||
if includeValue {
|
||||
result["value"] = record.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) GetWorkbenchOpenedResources() []coreworkbench.OpenedResource {
|
||||
return a.ensureWorkbench().OpenedResources()
|
||||
}
|
||||
@@ -1448,6 +1613,7 @@ func (a *App) SetCurrentVault(path string) string {
|
||||
if err := a.vault.OpenVault(path); err != nil {
|
||||
return fmt.Sprintf("failed to open vault: %v", err)
|
||||
}
|
||||
a.secretsSession = nil
|
||||
// Save the actual vault path (normalized by OpenVault, includes VerstakVault/)
|
||||
vaultPath := a.vault.GetVaultPath()
|
||||
if err := a.appSettings.SetCurrentVault(vaultPath); err != nil {
|
||||
|
||||
@@ -1432,6 +1432,17 @@ func newBridgeTestApp(t *testing.T) *App {
|
||||
Status: plugin.StatusDisabled,
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Manifest: plugin.Manifest{
|
||||
ID: "secrets.plugin",
|
||||
Name: "Secrets Plugin",
|
||||
Version: "1.0.0",
|
||||
Provides: []string{"secret-store", "secrets.read-ui", "secrets.write-ui"},
|
||||
Permissions: []string{"secrets.read", "secrets.write", "workbench.open"},
|
||||
},
|
||||
Status: plugin.StatusLoaded,
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1607,6 +1618,100 @@ func TestWorkbenchDisabledPluginProviderExcluded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSecretsRequirePermissionsAndUnlock(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
status, errStr := app.PluginSecretsStatus("secrets.plugin")
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsStatus: %s", errStr)
|
||||
}
|
||||
if status["unlocked"] == true {
|
||||
t.Fatalf("new secret session should be locked: %+v", status)
|
||||
}
|
||||
|
||||
if errStr := app.PluginSecretsUnlock("no.storage", "master password"); !strings.Contains(errStr, "secrets.read") {
|
||||
t.Fatalf("PluginSecretsUnlock err = %q, want secrets.read permission error", errStr)
|
||||
}
|
||||
if _, errStr := app.PluginSecretsList("secrets.plugin"); !strings.Contains(errStr, "locked") {
|
||||
t.Fatalf("PluginSecretsList before unlock err = %q, want locked", errStr)
|
||||
}
|
||||
|
||||
if errStr := app.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" {
|
||||
t.Fatalf("PluginSecretsUnlock: %s", errStr)
|
||||
}
|
||||
status, errStr = app.PluginSecretsStatus("secrets.plugin")
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsStatus after unlock: %s", errStr)
|
||||
}
|
||||
if status["unlocked"] != true {
|
||||
t.Fatalf("secret session not unlocked: %+v", status)
|
||||
}
|
||||
|
||||
writeResult, errStr := app.PluginSecretsWrite("secrets.plugin", map[string]interface{}{
|
||||
"id": "client-a.database",
|
||||
"title": "Client A Database",
|
||||
"value": "workspace-secret-value",
|
||||
"username": "app",
|
||||
"scope": map[string]interface{}{
|
||||
"kind": "workspace",
|
||||
"workspaceRootPath": "ClientA",
|
||||
},
|
||||
})
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsWrite: %s", errStr)
|
||||
}
|
||||
if writeResult["id"] != "client-a.database" || writeResult["value"] != nil {
|
||||
t.Fatalf("write result = %+v", writeResult)
|
||||
}
|
||||
|
||||
list, errStr := app.PluginSecretsList("secrets.plugin")
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsList: %s", errStr)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("PluginSecretsList len = %d, want 1: %+v", len(list), list)
|
||||
}
|
||||
if list[0]["value"] != nil {
|
||||
t.Fatalf("PluginSecretsList leaked value: %+v", list[0])
|
||||
}
|
||||
scope, _ := list[0]["scope"].(map[string]interface{})
|
||||
if scope["kind"] != "workspace" || scope["workspaceRootPath"] != "ClientA" {
|
||||
t.Fatalf("list scope = %+v", scope)
|
||||
}
|
||||
|
||||
readResult, errStr := app.PluginSecretsRead("secrets.plugin", "client-a.database")
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsRead: %s", errStr)
|
||||
}
|
||||
if readResult["value"] != "workspace-secret-value" {
|
||||
t.Fatalf("read result = %+v", readResult)
|
||||
}
|
||||
|
||||
link, errStr := app.PluginSecretsCopyLink("secrets.plugin", "client-a.database")
|
||||
if errStr != "" {
|
||||
t.Fatalf("PluginSecretsCopyLink: %s", errStr)
|
||||
}
|
||||
if link != "[Client A Database](verstak-secret://client-a.database)" {
|
||||
t.Fatalf("link = %q", link)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginSecretsRejectWrongMasterPasswordAcrossSessions(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
if errStr := app.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" {
|
||||
t.Fatalf("PluginSecretsUnlock first: %s", errStr)
|
||||
}
|
||||
|
||||
next := newBridgeTestApp(t)
|
||||
next.vault = app.vault
|
||||
if errStr := next.PluginSecretsUnlock("secrets.plugin", "wrong password"); !strings.Contains(errStr, "invalid master password") {
|
||||
t.Fatalf("wrong unlock err = %q, want invalid master password", errStr)
|
||||
}
|
||||
if errStr := next.PluginSecretsUnlock("secrets.plugin", "master password"); errStr != "" {
|
||||
t.Fatalf("PluginSecretsUnlock correct: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginBridgeSettingsRequireLoadedPluginAndStoragePermission(t *testing.T) {
|
||||
app := newBridgeTestApp(t)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user