feat: expose scoped secret store API

This commit is contained in:
2026-06-29 20:36:45 +08:00
parent 7a7b3c7a3e
commit 56c685212b
7 changed files with 808 additions and 22 deletions
+166
View File
@@ -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 {
+105
View File
@@ -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)