diff --git a/vault/internal/commands/adminclient_test.go b/vault/internal/commands/adminclient_test.go index 461747b..4612ffe 100644 --- a/vault/internal/commands/adminclient_test.go +++ b/vault/internal/commands/adminclient_test.go @@ -35,7 +35,7 @@ func adminUDSFixture(t *testing.T) (socket string, store *tokens.Store, shutdown Keys: server.KeysConfig{Path: t.TempDir(), EmbeddingDim: 1024}, } audit, _ := server.NewAuditLogger(server.AuditConfig{Mode: ""}) - v := server.NewVault(cfg, store, nil, audit) + v := server.NewVault(cfg, store, nil, nil, audit) stop, err := server.AdminFromConfig(context.Background(), v) if err != nil { diff --git a/vault/internal/commands/daemon.go b/vault/internal/commands/daemon.go index 8a61418..103a41e 100644 --- a/vault/internal/commands/daemon.go +++ b/vault/internal/commands/daemon.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" "github.com/CryptoLabInc/rune-admin/vault/internal/crypto" + "github.com/CryptoLabInc/rune-admin/vault/internal/denylist" "github.com/CryptoLabInc/rune-admin/vault/internal/server" "github.com/CryptoLabInc/rune-admin/vault/internal/tokens" ) @@ -51,6 +52,12 @@ func runDaemonStart(ctx context.Context) error { } defer store.Shutdown() + denyStore := denylist.NewStore() + if err := denyStore.LoadFromFile(cfg.Tokens.DenyListFile); err != nil { + return err + } + defer denyStore.Shutdown() + keyParams := crypto.KeysParams{ Root: cfg.Keys.Path, KeyID: "vault-key", @@ -71,7 +78,7 @@ func runDaemonStart(ctx context.Context) error { } defer audit.Close() - v := server.NewVault(cfg, store, keys, audit) + v := server.NewVault(cfg, store, denyStore, keys, audit) defer v.Close() slog.Info("vault: starting daemon", diff --git a/vault/internal/denylist/store.go b/vault/internal/denylist/store.go new file mode 100644 index 0000000..27bef00 --- /dev/null +++ b/vault/internal/denylist/store.go @@ -0,0 +1,230 @@ +// Package denylist implements the logical-delete deny-list: a per-index set +// of enVector item_ids that have been deleted. Vault is the single source of +// truth; clients consult it (FilterDeleted) and filter out deleted hits. +// Vault never talks to enVector and never filters scores itself. +package denylist + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "gopkg.in/yaml.v3" +) + +const persistDebounce = 100 * time.Millisecond + +// entry is the in-memory deny-list for a single index: the set of deleted +// item_ids plus a monotonic version that increments on every mutation. +type entry struct { + set map[uint64]struct{} + version uint64 +} + +// Store is a file-backed, debounce-persisted deny-list keyed by index name. +// Concurrency and persistence mirror tokens.Store. +type Store struct { + mu sync.RWMutex + byIndex map[string]*entry + path string + + persistMu sync.Mutex + persistTimer *time.Timer + persistWG sync.WaitGroup + persistClosed bool +} + +// NewStore returns an empty, unpersisted deny-list store. Call LoadFromFile to +// back it with a YAML file and enable persistence. +func NewStore() *Store { + return &Store{byIndex: make(map[string]*entry)} +} + +// fileDoc is the on-disk YAML shape: a map of index name -> {item_ids, version}. +type fileDoc struct { + Indexes map[string]indexDoc `yaml:"indexes"` +} + +type indexDoc struct { + ItemIDs []uint64 `yaml:"item_ids"` + Version uint64 `yaml:"version"` +} + +// LoadFromFile reads the deny-list from YAML at startup and enables persistence +// to path. A missing file is not an error: the store starts empty. +func (s *Store) LoadFromFile(path string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.path = path + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read deny-list file %s: %w", path, err) + } + var doc fileDoc + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("parse deny-list file %s: %w", path, err) + } + for name, idx := range doc.Indexes { + set := make(map[uint64]struct{}, len(idx.ItemIDs)) + for _, id := range idx.ItemIDs { + set[id] = struct{}{} + } + s.byIndex[name] = &entry{set: set, version: idx.Version} + } + return nil +} + +// MarkDeleted unions itemIDs into the deny-list for index and bumps its +// version. It is idempotent: re-marking already-deleted ids still bumps the +// version (a mutation was requested) but does not change membership. Returns +// the post-union deny-list size and the new version. +func (s *Store) MarkDeleted(index string, itemIDs []uint64) (count, version uint64) { + s.mu.Lock() + e, ok := s.byIndex[index] + if !ok { + e = &entry{set: make(map[uint64]struct{}, len(itemIDs))} + s.byIndex[index] = e + } + for _, id := range itemIDs { + e.set[id] = struct{}{} + } + e.version++ + count = uint64(len(e.set)) + version = e.version + s.mu.Unlock() + s.schedulePersist() + return count, version +} + +// FilterDeleted returns the subset of itemIDs that is on the deny-list for +// index, plus the index's current version. Cost is O(len(itemIDs)) and +// independent of the total deny-list size. Unknown index returns an empty +// subset and version 0. +func (s *Store) FilterDeleted(index string, itemIDs []uint64) (deleted []uint64, version uint64) { + s.mu.RLock() + defer s.mu.RUnlock() + e, ok := s.byIndex[index] + if !ok { + return nil, 0 + } + for _, id := range itemIDs { + if _, found := e.set[id]; found { + deleted = append(deleted, id) + } + } + return deleted, e.version +} + +// Shutdown cancels any pending persist and waits for in-flight writes. +// Use Flush instead to write pending changes before exit. +func (s *Store) Shutdown() { + s.persistMu.Lock() + s.persistClosed = true + if s.persistTimer != nil { + s.persistTimer.Stop() + s.persistTimer = nil + } + s.persistMu.Unlock() + s.persistWG.Wait() +} + +// Flush forces any pending debounced persist to run synchronously, then blocks +// until in-flight writes complete. +func (s *Store) Flush() { + s.persistMu.Lock() + pending := false + if s.persistTimer != nil { + if s.persistTimer.Stop() { + pending = true + } + s.persistTimer = nil + } + s.persistMu.Unlock() + if pending { + s.doPersist() + } + s.persistWG.Wait() +} + +func (s *Store) schedulePersist() { + s.persistMu.Lock() + defer s.persistMu.Unlock() + if s.persistClosed || s.path == "" { + return + } + if s.persistTimer != nil { + s.persistTimer.Stop() + } + s.persistTimer = time.AfterFunc(persistDebounce, func() { + s.persistMu.Lock() + s.persistTimer = nil + closed := s.persistClosed + s.persistMu.Unlock() + if closed { + return + } + s.doPersist() + }) +} + +func (s *Store) doPersist() { + s.persistWG.Add(1) + defer s.persistWG.Done() + + s.mu.RLock() + path := s.path + doc := fileDoc{Indexes: make(map[string]indexDoc, len(s.byIndex))} + for name, e := range s.byIndex { + ids := make([]uint64, 0, len(e.set)) + for id := range e.set { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + doc.Indexes[name] = indexDoc{ItemIDs: ids, Version: e.version} + } + s.mu.RUnlock() + + if err := atomicWriteYAML(path, doc); err != nil { + fmt.Fprintf(os.Stderr, "denylist: persist failed: %v\n", err) + } +} + +func atomicWriteYAML(path string, data any) error { + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".persist-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + enc := yaml.NewEncoder(tmp) + enc.SetIndent(2) + if err := enc.Encode(data); err != nil { + _ = enc.Close() + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + if err := enc.Close(); err != nil { + _ = tmp.Close() + _ = os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmpPath) + return err + } + return os.Rename(tmpPath, path) +} diff --git a/vault/internal/denylist/store_test.go b/vault/internal/denylist/store_test.go new file mode 100644 index 0000000..a4d6485 --- /dev/null +++ b/vault/internal/denylist/store_test.go @@ -0,0 +1,123 @@ +package denylist + +import ( + "path/filepath" + "sort" + "testing" +) + +func sortedEqual(got, want []uint64) bool { + if len(got) != len(want) { + return false + } + g := append([]uint64(nil), got...) + w := append([]uint64(nil), want...) + sort.Slice(g, func(i, j int) bool { return g[i] < g[j] }) + sort.Slice(w, func(i, j int) bool { return w[i] < w[j] }) + for i := range g { + if g[i] != w[i] { + return false + } + } + return true +} + +func TestMarkAndFilter(t *testing.T) { + s := NewStore() + count, version := s.MarkDeleted("idx", []uint64{1, 2, 3}) + if count != 3 { + t.Errorf("count = %d, want 3", count) + } + if version != 1 { + t.Errorf("version = %d, want 1", version) + } + + deleted, ver := s.FilterDeleted("idx", []uint64{2, 3, 4, 5}) + if !sortedEqual(deleted, []uint64{2, 3}) { + t.Errorf("deleted = %v, want [2 3]", deleted) + } + if ver != 1 { + t.Errorf("filter version = %d, want 1", ver) + } +} + +func TestMarkIsIdempotentUnion(t *testing.T) { + s := NewStore() + s.MarkDeleted("idx", []uint64{1, 2}) + count, version := s.MarkDeleted("idx", []uint64{2, 3}) + if count != 3 { + t.Errorf("count = %d, want 3 (union {1,2,3})", count) + } + if version != 2 { + t.Errorf("version = %d, want 2 (bumped each mark)", version) + } + // Re-marking an already-deleted id keeps membership but still bumps version. + count, version = s.MarkDeleted("idx", []uint64{1}) + if count != 3 { + t.Errorf("count = %d, want 3 (no new member)", count) + } + if version != 3 { + t.Errorf("version = %d, want 3", version) + } +} + +func TestFilterUnknownIndex(t *testing.T) { + s := NewStore() + deleted, version := s.FilterDeleted("nope", []uint64{1, 2}) + if len(deleted) != 0 { + t.Errorf("deleted = %v, want empty", deleted) + } + if version != 0 { + t.Errorf("version = %d, want 0", version) + } +} + +func TestPerIndexIsolation(t *testing.T) { + s := NewStore() + s.MarkDeleted("a", []uint64{1}) + s.MarkDeleted("b", []uint64{2}) + if d, _ := s.FilterDeleted("a", []uint64{1, 2}); !sortedEqual(d, []uint64{1}) { + t.Errorf("index a deleted = %v, want [1]", d) + } + if d, _ := s.FilterDeleted("b", []uint64{1, 2}); !sortedEqual(d, []uint64{2}) { + t.Errorf("index b deleted = %v, want [2]", d) + } +} + +func TestPersistAndReload(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "deny_list.yml") + + s1 := NewStore() + if err := s1.LoadFromFile(path); err != nil { + t.Fatalf("LoadFromFile: %v", err) + } + s1.MarkDeleted("idx", []uint64{10, 20, 30}) + s1.MarkDeleted("other", []uint64{99}) + s1.Flush() + + s2 := NewStore() + if err := s2.LoadFromFile(path); err != nil { + t.Fatalf("reload LoadFromFile: %v", err) + } + deleted, version := s2.FilterDeleted("idx", []uint64{10, 20, 30, 40}) + if !sortedEqual(deleted, []uint64{10, 20, 30}) { + t.Errorf("reloaded deleted = %v, want [10 20 30]", deleted) + } + if version != 1 { + t.Errorf("reloaded version = %d, want 1", version) + } + if d, _ := s2.FilterDeleted("other", []uint64{99}); !sortedEqual(d, []uint64{99}) { + t.Errorf("reloaded other = %v, want [99]", d) + } +} + +func TestLoadMissingFileIsEmpty(t *testing.T) { + s := NewStore() + if err := s.LoadFromFile(filepath.Join(t.TempDir(), "absent.yml")); err != nil { + t.Fatalf("LoadFromFile on missing file: %v", err) + } + if d, _ := s.FilterDeleted("idx", []uint64{1}); len(d) != 0 { + t.Errorf("deleted = %v, want empty", d) + } +} diff --git a/vault/internal/server/admin_test.go b/vault/internal/server/admin_test.go index a1a7726..c166cf4 100644 --- a/vault/internal/server/admin_test.go +++ b/vault/internal/server/admin_test.go @@ -26,7 +26,7 @@ func newAdminTestVault(t *testing.T) *Vault { store := tokens.NewStore() store.LoadDefaultsWithDemoToken() audit, _ := NewAuditLogger(AuditConfig{Mode: ""}) - return NewVault(cfg, store, nil, audit) + return NewVault(cfg, store, nil, nil, audit) } func adminTestServer(t *testing.T) (*httptest.Server, *Vault) { diff --git a/vault/internal/server/config.go b/vault/internal/server/config.go index 8f0d036..f7c266c 100644 --- a/vault/internal/server/config.go +++ b/vault/internal/server/config.go @@ -80,6 +80,9 @@ type TokensConfig struct { TeamSecretFile string `yaml:"team_secret_file"` RolesFile string `yaml:"roles_file"` TokensFile string `yaml:"tokens_file"` + // DenyListFile backs the logical-delete deny-list. Optional: when empty + // it defaults to deny_list.yml alongside TokensFile (see Resolve). + DenyListFile string `yaml:"deny_list_file"` } // AuditConfig.Mode is one of: "", "file", "stdout", "file+stdout". @@ -166,6 +169,9 @@ func (c *Config) Resolve() error { c.Tokens.TeamSecret = val c.Tokens.TeamSecretFile = "" } + if c.Tokens.DenyListFile == "" && c.Tokens.TokensFile != "" { + c.Tokens.DenyListFile = filepath.Join(filepath.Dir(c.Tokens.TokensFile), "deny_list.yml") + } return nil } diff --git a/vault/internal/server/grpc.go b/vault/internal/server/grpc.go index 582724b..4473a01 100644 --- a/vault/internal/server/grpc.go +++ b/vault/internal/server/grpc.go @@ -14,6 +14,7 @@ import ( "google.golang.org/grpc/status" "github.com/CryptoLabInc/rune-admin/vault/internal/crypto" + "github.com/CryptoLabInc/rune-admin/vault/internal/denylist" "github.com/CryptoLabInc/rune-admin/vault/internal/tokens" pb "github.com/CryptoLabInc/rune-admin/vault/pkg/vaultpb" ) @@ -25,10 +26,11 @@ const MaxMessageSize = 256 * 1024 * 1024 // admin UDS server. It owns the long-lived token store, FHE key handle, // and audit logger. Construct via NewVault, tear down via Close. type Vault struct { - cfg *Config - tokens *tokens.Store - keys *crypto.EnvectorKeys - audit *AuditLogger + cfg *Config + tokens *tokens.Store + denylist *denylist.Store + keys *crypto.EnvectorKeys + audit *AuditLogger // Cached bundle pieces from disk. Re-read on demand to pick up // rotated keys without restarting; kept here for zero-copy reuse. @@ -36,12 +38,13 @@ type Vault struct { } // NewVault wires all subsystems together. Caller is responsible for Close. -func NewVault(cfg *Config, tokenStore *tokens.Store, keys *crypto.EnvectorKeys, audit *AuditLogger) *Vault { +func NewVault(cfg *Config, tokenStore *tokens.Store, denyStore *denylist.Store, keys *crypto.EnvectorKeys, audit *AuditLogger) *Vault { return &Vault{ - cfg: cfg, - tokens: tokenStore, - keys: keys, - audit: audit, + cfg: cfg, + tokens: tokenStore, + denylist: denyStore, + keys: keys, + audit: audit, bundleParams: crypto.KeysParams{ Root: cfg.Keys.Path, KeyID: defaultKeyID(cfg), @@ -318,6 +321,86 @@ func (s *VaultGRPC) DecryptMetadata(ctx context.Context, req *pb.DecryptMetadata return &pb.DecryptMetadataResponse{DecryptedMetadata: out}, nil } +// ── MarkDeleted ─────────────────────────────────────────────────── + +func (s *VaultGRPC) MarkDeleted(ctx context.Context, req *pb.MarkDeletedRequest) (*pb.MarkDeletedResponse, error) { + start := time.Now() + user := s.v.tokens.GetUsername(req.GetToken()) + if user == "" { + user = "unknown" + } + resultCount := 0 + statusStr := "success" + var errDetail *string + defer func() { + s.emit(ctx, "mark_deleted", user, nil, resultCount, statusStr, errDetail, time.Since(start)) + }() + + username, role, err := s.v.tokens.Validate(req.GetToken()) + if err != nil { + st, msg := mapTokenError(err) + statusStr, errDetail = errStatus(err) + return &pb.MarkDeletedResponse{Error: msg}, status.Error(st, msg) + } + user = username + if err := role.CheckScope("mark_deleted"); err != nil { + statusStr = "denied" + ed := err.Error() + errDetail = &ed + return &pb.MarkDeletedResponse{Error: err.Error()}, status.Error(codes.PermissionDenied, err.Error()) + } + if s.v.denylist == nil { + statusStr = "error" + msg := "deny-list store not configured" + errDetail = &msg + return &pb.MarkDeletedResponse{Error: msg}, status.Error(codes.Internal, msg) + } + + count, version := s.v.denylist.MarkDeleted(req.GetIndexName(), req.GetItemIds()) + resultCount = len(req.GetItemIds()) + return &pb.MarkDeletedResponse{Count: count, Version: version}, nil +} + +// ── FilterDeleted ────────────────────────────────────────────────── + +func (s *VaultGRPC) FilterDeleted(ctx context.Context, req *pb.FilterDeletedRequest) (*pb.FilterDeletedResponse, error) { + start := time.Now() + user := s.v.tokens.GetUsername(req.GetToken()) + if user == "" { + user = "unknown" + } + resultCount := 0 + statusStr := "success" + var errDetail *string + defer func() { + s.emit(ctx, "filter_deleted", user, nil, resultCount, statusStr, errDetail, time.Since(start)) + }() + + username, role, err := s.v.tokens.Validate(req.GetToken()) + if err != nil { + st, msg := mapTokenError(err) + statusStr, errDetail = errStatus(err) + return &pb.FilterDeletedResponse{Error: msg}, status.Error(st, msg) + } + user = username + if err := role.CheckScope("filter_deleted"); err != nil { + statusStr = "denied" + ed := err.Error() + errDetail = &ed + return &pb.FilterDeletedResponse{Error: err.Error()}, status.Error(codes.PermissionDenied, err.Error()) + } + if s.v.denylist == nil { + statusStr = "error" + msg := "deny-list store not configured" + errDetail = &msg + return &pb.FilterDeletedResponse{Error: msg}, status.Error(codes.Internal, msg) + } + + deleted, version := s.v.denylist.FilterDeleted(req.GetIndexName(), req.GetItemIds()) + resultCount = len(deleted) + return &pb.FilterDeletedResponse{DeletedItemIds: deleted, Version: version}, nil +} + // ── error mapping & audit helpers ──────────────────────────────── // mapTokenError maps tokens.ErrXxx → (gRPC code, user-facing message). diff --git a/vault/internal/server/grpc_test.go b/vault/internal/server/grpc_test.go index 8b913c9..ea3568b 100644 --- a/vault/internal/server/grpc_test.go +++ b/vault/internal/server/grpc_test.go @@ -9,6 +9,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/CryptoLabInc/rune-admin/vault/internal/denylist" "github.com/CryptoLabInc/rune-admin/vault/internal/tokens" pb "github.com/CryptoLabInc/rune-admin/vault/pkg/vaultpb" ) @@ -100,7 +101,98 @@ func newTestVault(t *testing.T) *Vault { store := tokens.NewStore() store.LoadDefaultsWithDemoToken() audit, _ := NewAuditLogger(AuditConfig{Mode: ""}) - return NewVault(cfg, store, nil, audit) + return NewVault(cfg, store, denylist.NewStore(), nil, audit) +} + +const badToken = "evt_ffffffffffffffffffffffffffffffff" + +// ── MarkDeleted / FilterDeleted ─────────────────────────────────── + +func TestMarkDeletedInvalidToken(t *testing.T) { + srv := NewVaultGRPC(newTestVault(t)) + resp, err := srv.MarkDeleted(context.Background(), &pb.MarkDeletedRequest{ + Token: badToken, IndexName: "idx", ItemIds: []uint64{1}, + }) + if status.Code(err) != codes.Unauthenticated { + t.Errorf("code = %v, want Unauthenticated", status.Code(err)) + } + if resp.GetError() == "" { + t.Error("response.error is empty") + } +} + +func TestFilterDeletedInvalidToken(t *testing.T) { + srv := NewVaultGRPC(newTestVault(t)) + resp, err := srv.FilterDeleted(context.Background(), &pb.FilterDeletedRequest{ + Token: badToken, IndexName: "idx", ItemIds: []uint64{1}, + }) + if status.Code(err) != codes.Unauthenticated { + t.Errorf("code = %v, want Unauthenticated", status.Code(err)) + } + if resp.GetError() == "" { + t.Error("response.error is empty") + } +} + +func TestMarkDeletedScopeDenied(t *testing.T) { + v := newTestVault(t) + memberTok, err := v.tokens.AddToken("bob", "member", nil) + if err != nil { + t.Fatalf("AddToken: %v", err) + } + srv := NewVaultGRPC(v) + _, err = srv.MarkDeleted(context.Background(), &pb.MarkDeletedRequest{ + Token: memberTok.Token, IndexName: "idx", ItemIds: []uint64{1}, + }) + if status.Code(err) != codes.PermissionDenied { + t.Errorf("code = %v, want PermissionDenied (member lacks mark_deleted)", status.Code(err)) + } +} + +func TestMarkThenFilterRoundTrip(t *testing.T) { + srv := NewVaultGRPC(newTestVault(t)) + ctx := context.Background() + + mark, err := srv.MarkDeleted(ctx, &pb.MarkDeletedRequest{ + Token: tokens.DemoToken, IndexName: "idx", ItemIds: []uint64{1, 2, 3}, + }) + if err != nil { + t.Fatalf("MarkDeleted: %v", err) + } + if mark.GetCount() != 3 || mark.GetVersion() != 1 { + t.Errorf("mark = {count:%d version:%d}, want {3 1}", mark.GetCount(), mark.GetVersion()) + } + + filt, err := srv.FilterDeleted(ctx, &pb.FilterDeletedRequest{ + Token: tokens.DemoToken, IndexName: "idx", ItemIds: []uint64{2, 3, 4}, + }) + if err != nil { + t.Fatalf("FilterDeleted: %v", err) + } + got := map[uint64]bool{} + for _, id := range filt.GetDeletedItemIds() { + got[id] = true + } + if len(got) != 2 || !got[2] || !got[3] { + t.Errorf("deleted = %v, want {2,3}", filt.GetDeletedItemIds()) + } + if filt.GetVersion() != 1 { + t.Errorf("filter version = %d, want 1", filt.GetVersion()) + } +} + +func TestFilterDeletedMemberAllowed(t *testing.T) { + v := newTestVault(t) + memberTok, err := v.tokens.AddToken("carol", "member", nil) + if err != nil { + t.Fatalf("AddToken: %v", err) + } + srv := NewVaultGRPC(v) + if _, err := srv.FilterDeleted(context.Background(), &pb.FilterDeletedRequest{ + Token: memberTok.Token, IndexName: "idx", ItemIds: []uint64{1}, + }); err != nil { + t.Errorf("FilterDeleted for member: %v", err) + } } func TestGetAgentManifestInvalidToken(t *testing.T) { diff --git a/vault/internal/server/interceptors.go b/vault/internal/server/interceptors.go index 6de1fd8..0114c10 100644 --- a/vault/internal/server/interceptors.go +++ b/vault/internal/server/interceptors.go @@ -22,6 +22,8 @@ var vaultMethods = map[string]bool{ "/rune.vault.v1.VaultService/GetAgentManifest": true, "/rune.vault.v1.VaultService/DecryptScores": true, "/rune.vault.v1.VaultService/DecryptMetadata": true, + "/rune.vault.v1.VaultService/MarkDeleted": true, + "/rune.vault.v1.VaultService/FilterDeleted": true, } // NewValidationInterceptor returns a unary server interceptor that runs @@ -61,6 +63,10 @@ func runtimeCheckToken(req any) error { token = r.GetToken() case *pb.DecryptMetadataRequest: token = r.GetToken() + case *pb.MarkDeletedRequest: + token = r.GetToken() + case *pb.FilterDeletedRequest: + token = r.GetToken() default: return nil } diff --git a/vault/internal/server/interceptors_test.go b/vault/internal/server/interceptors_test.go index f30021a..fd8408b 100644 --- a/vault/internal/server/interceptors_test.go +++ b/vault/internal/server/interceptors_test.go @@ -92,6 +92,24 @@ func TestInterceptorRejectsControlCharToken(t *testing.T) { } } +// TestVaultMethodsCoversAllRPCs guards against a new VaultService RPC being +// added without registering it in vaultMethods (and thus silently skipping the +// runtime token-safety check). The expected set is derived from the generated +// ServiceDesc, so it stays correct as the service grows. +func TestVaultMethodsCoversAllRPCs(t *testing.T) { + prefix := "/" + pb.VaultService_ServiceDesc.ServiceName + "/" + for _, m := range pb.VaultService_ServiceDesc.Methods { + full := prefix + m.MethodName + if !vaultMethods[full] { + t.Errorf("RPC %q is missing from vaultMethods — runtime token check would be skipped", full) + } + } + if len(vaultMethods) != len(pb.VaultService_ServiceDesc.Methods) { + t.Errorf("vaultMethods has %d entries, ServiceDesc has %d methods — stale entry?", + len(vaultMethods), len(pb.VaultService_ServiceDesc.Methods)) + } +} + func TestInterceptorAllowsNonVaultMethod(t *testing.T) { ic := mustInterceptor(t) // Whitespace-around token would normally fail runtime check, but diff --git a/vault/internal/server/testdata/runevault.conf.example b/vault/internal/server/testdata/runevault.conf.example index 70243e9..9c2c519 100644 --- a/vault/internal/server/testdata/runevault.conf.example +++ b/vault/internal/server/testdata/runevault.conf.example @@ -34,6 +34,8 @@ tokens: # Alternative: team_secret_file: /run/secrets/team_secret roles_file: /opt/rune-vault/configs/roles.yml tokens_file: /opt/rune-vault/configs/tokens.yml + # Logical-delete deny-list. Optional — defaults to deny_list.yml beside tokens_file. + # deny_list_file: /opt/rune-vault/configs/deny_list.yml audit: mode: file+stdout # one of: "", file, stdout, file+stdout diff --git a/vault/internal/tests/decrypt_pipeline_test.go b/vault/internal/tests/decrypt_pipeline_test.go index 7310e32..23c695e 100644 --- a/vault/internal/tests/decrypt_pipeline_test.go +++ b/vault/internal/tests/decrypt_pipeline_test.go @@ -117,7 +117,7 @@ func fixtureVault(t *testing.T, fb *fixtureBundle) *server.Vault { injectFixtureToken(t, store, fb.Config.Token) } audit, _ := server.NewAuditLogger(server.AuditConfig{Mode: ""}) - return server.NewVault(cfg, store, keys, audit) + return server.NewVault(cfg, store, nil, keys, audit) } func injectFixtureToken(t *testing.T, store *tokens.Store, token string) { diff --git a/vault/internal/tokens/role.go b/vault/internal/tokens/role.go index 52ea586..342b405 100644 --- a/vault/internal/tokens/role.go +++ b/vault/internal/tokens/role.go @@ -48,13 +48,13 @@ func DefaultRoles() map[string]*Role { return map[string]*Role{ "admin": { Name: "admin", - Scope: []string{"get_public_key", "decrypt_scores", "decrypt_metadata", "manage_tokens"}, + Scope: []string{"get_public_key", "decrypt_scores", "decrypt_metadata", "manage_tokens", "mark_deleted", "filter_deleted"}, TopK: 50, RateLimit: "150/60s", }, "member": { Name: "member", - Scope: []string{"get_public_key", "decrypt_scores", "decrypt_metadata"}, + Scope: []string{"get_public_key", "decrypt_scores", "decrypt_metadata", "filter_deleted"}, TopK: 10, RateLimit: "30/60s", }, diff --git a/vault/internal/tokens/store_test.go b/vault/internal/tokens/store_test.go index 58c16eb..b54d3b1 100644 --- a/vault/internal/tokens/store_test.go +++ b/vault/internal/tokens/store_test.go @@ -501,6 +501,23 @@ func TestScopeAllowsValidMethod(t *testing.T) { } } +func TestDefaultRolesDeleteScopes(t *testing.T) { + roles := DefaultRoles() + // admin may both mark and filter; member may only filter (recall path). + if err := roles["admin"].CheckScope("mark_deleted"); err != nil { + t.Errorf("admin mark_deleted: %v", err) + } + if err := roles["admin"].CheckScope("filter_deleted"); err != nil { + t.Errorf("admin filter_deleted: %v", err) + } + if err := roles["member"].CheckScope("filter_deleted"); err != nil { + t.Errorf("member filter_deleted: %v", err) + } + if err := roles["member"].CheckScope("mark_deleted"); err == nil { + t.Error("member mark_deleted: want denied, got nil") + } +} + func TestScopeRejectsInvalidMethod(t *testing.T) { r := &Role{Name: "limited", Scope: []string{"get_public_key"}} err := r.CheckScope("decrypt_scores") diff --git a/vault/pkg/vaultpb/vault_service.pb.go b/vault/pkg/vaultpb/vault_service.pb.go index 0ecb708..c4c3121 100644 --- a/vault/pkg/vaultpb/vault_service.pb.go +++ b/vault/pkg/vaultpb/vault_service.pb.go @@ -403,6 +403,256 @@ func (x *DecryptMetadataResponse) GetError() string { return "" } +type MarkDeletedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Auth token. Required, Fixed 36 chars (evt_ + 32 hex). + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // enVector index the deny-list belongs to. Required. + IndexName string `protobuf:"bytes,2,opt,name=index_name,json=indexName,proto3" json:"index_name,omitempty"` + // Stable enVector item_ids to mark deleted. Required, max 10000, deduped server-side. + ItemIds []uint64 `protobuf:"varint,3,rep,packed,name=item_ids,json=itemIds,proto3" json:"item_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MarkDeletedRequest) Reset() { + *x = MarkDeletedRequest{} + mi := &file_vault_service_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MarkDeletedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarkDeletedRequest) ProtoMessage() {} + +func (x *MarkDeletedRequest) ProtoReflect() protoreflect.Message { + mi := &file_vault_service_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarkDeletedRequest.ProtoReflect.Descriptor instead. +func (*MarkDeletedRequest) Descriptor() ([]byte, []int) { + return file_vault_service_proto_rawDescGZIP(), []int{7} +} + +func (x *MarkDeletedRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *MarkDeletedRequest) GetIndexName() string { + if x != nil { + return x.IndexName + } + return "" +} + +func (x *MarkDeletedRequest) GetItemIds() []uint64 { + if x != nil { + return x.ItemIds + } + return nil +} + +type MarkDeletedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Total deny-list size for the index after this mark (post-union). + Count uint64 `protobuf:"varint,1,opt,name=count,proto3" json:"count,omitempty"` + // Monotonic deny-list version for the index after this mark. + Version uint64 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` // Non-empty on error + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MarkDeletedResponse) Reset() { + *x = MarkDeletedResponse{} + mi := &file_vault_service_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MarkDeletedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MarkDeletedResponse) ProtoMessage() {} + +func (x *MarkDeletedResponse) ProtoReflect() protoreflect.Message { + mi := &file_vault_service_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MarkDeletedResponse.ProtoReflect.Descriptor instead. +func (*MarkDeletedResponse) Descriptor() ([]byte, []int) { + return file_vault_service_proto_rawDescGZIP(), []int{8} +} + +func (x *MarkDeletedResponse) GetCount() uint64 { + if x != nil { + return x.Count + } + return 0 +} + +func (x *MarkDeletedResponse) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *MarkDeletedResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +type FilterDeletedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Auth token. Required, Fixed 36 chars (evt_ + 32 hex). + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + // enVector index the deny-list belongs to. Required. + IndexName string `protobuf:"bytes,2,opt,name=index_name,json=indexName,proto3" json:"index_name,omitempty"` + // Candidate item_ids to check. Required, max 10000. + ItemIds []uint64 `protobuf:"varint,3,rep,packed,name=item_ids,json=itemIds,proto3" json:"item_ids,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilterDeletedRequest) Reset() { + *x = FilterDeletedRequest{} + mi := &file_vault_service_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilterDeletedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilterDeletedRequest) ProtoMessage() {} + +func (x *FilterDeletedRequest) ProtoReflect() protoreflect.Message { + mi := &file_vault_service_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilterDeletedRequest.ProtoReflect.Descriptor instead. +func (*FilterDeletedRequest) Descriptor() ([]byte, []int) { + return file_vault_service_proto_rawDescGZIP(), []int{9} +} + +func (x *FilterDeletedRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *FilterDeletedRequest) GetIndexName() string { + if x != nil { + return x.IndexName + } + return "" +} + +func (x *FilterDeletedRequest) GetItemIds() []uint64 { + if x != nil { + return x.ItemIds + } + return nil +} + +type FilterDeletedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Subset of the requested item_ids that is on the deny-list. + DeletedItemIds []uint64 `protobuf:"varint,1,rep,packed,name=deleted_item_ids,json=deletedItemIds,proto3" json:"deleted_item_ids,omitempty"` + // Monotonic deny-list version for the index at read time. + Version uint64 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` + Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"` // Non-empty on error + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FilterDeletedResponse) Reset() { + *x = FilterDeletedResponse{} + mi := &file_vault_service_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FilterDeletedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FilterDeletedResponse) ProtoMessage() {} + +func (x *FilterDeletedResponse) ProtoReflect() protoreflect.Message { + mi := &file_vault_service_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FilterDeletedResponse.ProtoReflect.Descriptor instead. +func (*FilterDeletedResponse) Descriptor() ([]byte, []int) { + return file_vault_service_proto_rawDescGZIP(), []int{10} +} + +func (x *FilterDeletedResponse) GetDeletedItemIds() []uint64 { + if x != nil { + return x.DeletedItemIds + } + return nil +} + +func (x *FilterDeletedResponse) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *FilterDeletedResponse) GetError() string { + if x != nil { + return x.Error + } + return "" +} + var File_vault_service_proto protoreflect.FileDescriptor const file_vault_service_proto_rawDesc = "" + @@ -431,11 +681,31 @@ const file_vault_service_proto_rawDesc = "" + "\x17encrypted_metadata_list\x18\x02 \x03(\tB\x11\xbaH\x0e\x92\x01\v\b\x01\x10\xe8\a\"\x04r\x02\x10\x01R\x15encryptedMetadataList\"^\n" + "\x17DecryptMetadataResponse\x12-\n" + "\x12decrypted_metadata\x18\x01 \x03(\tR\x11decryptedMetadata\x12\x14\n" + - "\x05error\x18\x02 \x01(\tR\x05error2\xb1\x02\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x85\x01\n" + + "\x12MarkDeletedRequest\x12\x1f\n" + + "\x05token\x18\x01 \x01(\tB\t\xbaH\x06r\x04\x10$\x18$R\x05token\x12&\n" + + "\n" + + "index_name\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tindexName\x12&\n" + + "\bitem_ids\x18\x03 \x03(\x04B\v\xbaH\b\x92\x01\x05\b\x01\x10\x90NR\aitemIds\"[\n" + + "\x13MarkDeletedResponse\x12\x14\n" + + "\x05count\x18\x01 \x01(\x04R\x05count\x12\x18\n" + + "\aversion\x18\x02 \x01(\x04R\aversion\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error\"\x87\x01\n" + + "\x14FilterDeletedRequest\x12\x1f\n" + + "\x05token\x18\x01 \x01(\tB\t\xbaH\x06r\x04\x10$\x18$R\x05token\x12&\n" + + "\n" + + "index_name\x18\x02 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\tindexName\x12&\n" + + "\bitem_ids\x18\x03 \x03(\x04B\v\xbaH\b\x92\x01\x05\b\x01\x10\x90NR\aitemIds\"q\n" + + "\x15FilterDeletedResponse\x12(\n" + + "\x10deleted_item_ids\x18\x01 \x03(\x04R\x0edeletedItemIds\x12\x18\n" + + "\aversion\x18\x02 \x01(\x04R\aversion\x12\x14\n" + + "\x05error\x18\x03 \x01(\tR\x05error2\xe3\x03\n" + "\fVaultService\x12c\n" + "\x10GetAgentManifest\x12&.rune.vault.v1.GetAgentManifestRequest\x1a'.rune.vault.v1.GetAgentManifestResponse\x12Z\n" + "\rDecryptScores\x12#.rune.vault.v1.DecryptScoresRequest\x1a$.rune.vault.v1.DecryptScoresResponse\x12`\n" + - "\x0fDecryptMetadata\x12%.rune.vault.v1.DecryptMetadataRequest\x1a&.rune.vault.v1.DecryptMetadataResponseb\x06proto3" + "\x0fDecryptMetadata\x12%.rune.vault.v1.DecryptMetadataRequest\x1a&.rune.vault.v1.DecryptMetadataResponse\x12T\n" + + "\vMarkDeleted\x12!.rune.vault.v1.MarkDeletedRequest\x1a\".rune.vault.v1.MarkDeletedResponse\x12Z\n" + + "\rFilterDeleted\x12#.rune.vault.v1.FilterDeletedRequest\x1a$.rune.vault.v1.FilterDeletedResponseb\x06proto3" var ( file_vault_service_proto_rawDescOnce sync.Once @@ -449,7 +719,7 @@ func file_vault_service_proto_rawDescGZIP() []byte { return file_vault_service_proto_rawDescData } -var file_vault_service_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_vault_service_proto_msgTypes = make([]protoimpl.MessageInfo, 11) var file_vault_service_proto_goTypes = []any{ (*GetAgentManifestRequest)(nil), // 0: rune.vault.v1.GetAgentManifestRequest (*GetAgentManifestResponse)(nil), // 1: rune.vault.v1.GetAgentManifestResponse @@ -458,20 +728,28 @@ var file_vault_service_proto_goTypes = []any{ (*DecryptScoresResponse)(nil), // 4: rune.vault.v1.DecryptScoresResponse (*DecryptMetadataRequest)(nil), // 5: rune.vault.v1.DecryptMetadataRequest (*DecryptMetadataResponse)(nil), // 6: rune.vault.v1.DecryptMetadataResponse + (*MarkDeletedRequest)(nil), // 7: rune.vault.v1.MarkDeletedRequest + (*MarkDeletedResponse)(nil), // 8: rune.vault.v1.MarkDeletedResponse + (*FilterDeletedRequest)(nil), // 9: rune.vault.v1.FilterDeletedRequest + (*FilterDeletedResponse)(nil), // 10: rune.vault.v1.FilterDeletedResponse } var file_vault_service_proto_depIdxs = []int32{ - 3, // 0: rune.vault.v1.DecryptScoresResponse.results:type_name -> rune.vault.v1.ScoreEntry - 0, // 1: rune.vault.v1.VaultService.GetAgentManifest:input_type -> rune.vault.v1.GetAgentManifestRequest - 2, // 2: rune.vault.v1.VaultService.DecryptScores:input_type -> rune.vault.v1.DecryptScoresRequest - 5, // 3: rune.vault.v1.VaultService.DecryptMetadata:input_type -> rune.vault.v1.DecryptMetadataRequest - 1, // 4: rune.vault.v1.VaultService.GetAgentManifest:output_type -> rune.vault.v1.GetAgentManifestResponse - 4, // 5: rune.vault.v1.VaultService.DecryptScores:output_type -> rune.vault.v1.DecryptScoresResponse - 6, // 6: rune.vault.v1.VaultService.DecryptMetadata:output_type -> rune.vault.v1.DecryptMetadataResponse - 4, // [4:7] is the sub-list for method output_type - 1, // [1:4] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 3, // 0: rune.vault.v1.DecryptScoresResponse.results:type_name -> rune.vault.v1.ScoreEntry + 0, // 1: rune.vault.v1.VaultService.GetAgentManifest:input_type -> rune.vault.v1.GetAgentManifestRequest + 2, // 2: rune.vault.v1.VaultService.DecryptScores:input_type -> rune.vault.v1.DecryptScoresRequest + 5, // 3: rune.vault.v1.VaultService.DecryptMetadata:input_type -> rune.vault.v1.DecryptMetadataRequest + 7, // 4: rune.vault.v1.VaultService.MarkDeleted:input_type -> rune.vault.v1.MarkDeletedRequest + 9, // 5: rune.vault.v1.VaultService.FilterDeleted:input_type -> rune.vault.v1.FilterDeletedRequest + 1, // 6: rune.vault.v1.VaultService.GetAgentManifest:output_type -> rune.vault.v1.GetAgentManifestResponse + 4, // 7: rune.vault.v1.VaultService.DecryptScores:output_type -> rune.vault.v1.DecryptScoresResponse + 6, // 8: rune.vault.v1.VaultService.DecryptMetadata:output_type -> rune.vault.v1.DecryptMetadataResponse + 8, // 9: rune.vault.v1.VaultService.MarkDeleted:output_type -> rune.vault.v1.MarkDeletedResponse + 10, // 10: rune.vault.v1.VaultService.FilterDeleted:output_type -> rune.vault.v1.FilterDeletedResponse + 6, // [6:11] is the sub-list for method output_type + 1, // [1:6] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_vault_service_proto_init() } @@ -485,7 +763,7 @@ func file_vault_service_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_vault_service_proto_rawDesc), len(file_vault_service_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 11, NumExtensions: 0, NumServices: 1, }, diff --git a/vault/pkg/vaultpb/vault_service_grpc.pb.go b/vault/pkg/vaultpb/vault_service_grpc.pb.go index 8d48049..4075eb8 100644 --- a/vault/pkg/vaultpb/vault_service_grpc.pb.go +++ b/vault/pkg/vaultpb/vault_service_grpc.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.6.1 +// - protoc-gen-go-grpc v1.6.2 // - protoc (unknown) // source: vault_service.proto @@ -22,6 +22,8 @@ const ( VaultService_GetAgentManifest_FullMethodName = "/rune.vault.v1.VaultService/GetAgentManifest" VaultService_DecryptScores_FullMethodName = "/rune.vault.v1.VaultService/DecryptScores" VaultService_DecryptMetadata_FullMethodName = "/rune.vault.v1.VaultService/DecryptMetadata" + VaultService_MarkDeleted_FullMethodName = "/rune.vault.v1.VaultService/MarkDeleted" + VaultService_FilterDeleted_FullMethodName = "/rune.vault.v1.VaultService/FilterDeleted" ) // VaultServiceClient is the client API for VaultService service. @@ -37,6 +39,12 @@ type VaultServiceClient interface { DecryptScores(ctx context.Context, in *DecryptScoresRequest, opts ...grpc.CallOption) (*DecryptScoresResponse, error) // Decrypts a list of AES-encrypted metadata strings. DecryptMetadata(ctx context.Context, in *DecryptMetadataRequest, opts ...grpc.CallOption) (*DecryptMetadataResponse, error) + // Marks item_ids as logically deleted by unioning them into the + // per-index deny-list. Idempotent. Write scope: mark_deleted. + MarkDeleted(ctx context.Context, in *MarkDeletedRequest, opts ...grpc.CallOption) (*MarkDeletedResponse, error) + // Given candidate item_ids, returns the subset that is on the + // per-index deny-list. Read scope: filter_deleted. + FilterDeleted(ctx context.Context, in *FilterDeletedRequest, opts ...grpc.CallOption) (*FilterDeletedResponse, error) } type vaultServiceClient struct { @@ -77,6 +85,26 @@ func (c *vaultServiceClient) DecryptMetadata(ctx context.Context, in *DecryptMet return out, nil } +func (c *vaultServiceClient) MarkDeleted(ctx context.Context, in *MarkDeletedRequest, opts ...grpc.CallOption) (*MarkDeletedResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(MarkDeletedResponse) + err := c.cc.Invoke(ctx, VaultService_MarkDeleted_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vaultServiceClient) FilterDeleted(ctx context.Context, in *FilterDeletedRequest, opts ...grpc.CallOption) (*FilterDeletedResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(FilterDeletedResponse) + err := c.cc.Invoke(ctx, VaultService_FilterDeleted_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // VaultServiceServer is the server API for VaultService service. // All implementations must embed UnimplementedVaultServiceServer // for forward compatibility. @@ -90,6 +118,12 @@ type VaultServiceServer interface { DecryptScores(context.Context, *DecryptScoresRequest) (*DecryptScoresResponse, error) // Decrypts a list of AES-encrypted metadata strings. DecryptMetadata(context.Context, *DecryptMetadataRequest) (*DecryptMetadataResponse, error) + // Marks item_ids as logically deleted by unioning them into the + // per-index deny-list. Idempotent. Write scope: mark_deleted. + MarkDeleted(context.Context, *MarkDeletedRequest) (*MarkDeletedResponse, error) + // Given candidate item_ids, returns the subset that is on the + // per-index deny-list. Read scope: filter_deleted. + FilterDeleted(context.Context, *FilterDeletedRequest) (*FilterDeletedResponse, error) mustEmbedUnimplementedVaultServiceServer() } @@ -109,6 +143,12 @@ func (UnimplementedVaultServiceServer) DecryptScores(context.Context, *DecryptSc func (UnimplementedVaultServiceServer) DecryptMetadata(context.Context, *DecryptMetadataRequest) (*DecryptMetadataResponse, error) { return nil, status.Error(codes.Unimplemented, "method DecryptMetadata not implemented") } +func (UnimplementedVaultServiceServer) MarkDeleted(context.Context, *MarkDeletedRequest) (*MarkDeletedResponse, error) { + return nil, status.Error(codes.Unimplemented, "method MarkDeleted not implemented") +} +func (UnimplementedVaultServiceServer) FilterDeleted(context.Context, *FilterDeletedRequest) (*FilterDeletedResponse, error) { + return nil, status.Error(codes.Unimplemented, "method FilterDeleted not implemented") +} func (UnimplementedVaultServiceServer) mustEmbedUnimplementedVaultServiceServer() {} func (UnimplementedVaultServiceServer) testEmbeddedByValue() {} @@ -184,6 +224,42 @@ func _VaultService_DecryptMetadata_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _VaultService_MarkDeleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(MarkDeletedRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).MarkDeleted(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_MarkDeleted_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).MarkDeleted(ctx, req.(*MarkDeletedRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VaultService_FilterDeleted_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(FilterDeletedRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VaultServiceServer).FilterDeleted(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VaultService_FilterDeleted_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VaultServiceServer).FilterDeleted(ctx, req.(*FilterDeletedRequest)) + } + return interceptor(ctx, in, info, handler) +} + // VaultService_ServiceDesc is the grpc.ServiceDesc for VaultService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -203,6 +279,14 @@ var VaultService_ServiceDesc = grpc.ServiceDesc{ MethodName: "DecryptMetadata", Handler: _VaultService_DecryptMetadata_Handler, }, + { + MethodName: "MarkDeleted", + Handler: _VaultService_MarkDeleted_Handler, + }, + { + MethodName: "FilterDeleted", + Handler: _VaultService_FilterDeleted_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "vault_service.proto", diff --git a/vault/proto/vault_service.proto b/vault/proto/vault_service.proto index c3e6a03..046ae1b 100644 --- a/vault/proto/vault_service.proto +++ b/vault/proto/vault_service.proto @@ -15,6 +15,14 @@ service VaultService { // Decrypts a list of AES-encrypted metadata strings. rpc DecryptMetadata(DecryptMetadataRequest) returns (DecryptMetadataResponse); + + // Marks item_ids as logically deleted by unioning them into the + // per-index deny-list. Idempotent. Write scope: mark_deleted. + rpc MarkDeleted(MarkDeletedRequest) returns (MarkDeletedResponse); + + // Given candidate item_ids, returns the subset that is on the + // per-index deny-list. Read scope: filter_deleted. + rpc FilterDeleted(FilterDeletedRequest) returns (FilterDeletedResponse); } // ─── GetAgentManifest ───────────────────────────────────────────── @@ -71,3 +79,47 @@ message DecryptMetadataResponse { repeated string decrypted_metadata = 1; string error = 2; // Non-empty on error } + +// ─── MarkDeleted ─────────────────────────────────────────────────── + +message MarkDeletedRequest { + // Auth token. Required, Fixed 36 chars (evt_ + 32 hex). + string token = 1 [(buf.validate.field).string = {min_len: 36, max_len: 36}]; + // enVector index the deny-list belongs to. Required. + string index_name = 2 [(buf.validate.field).string.min_len = 1]; + // Stable enVector item_ids to mark deleted. Required, max 10000, deduped server-side. + repeated uint64 item_ids = 3 [(buf.validate.field).repeated = { + min_items: 1, + max_items: 10000 + }]; +} + +message MarkDeletedResponse { + // Total deny-list size for the index after this mark (post-union). + uint64 count = 1; + // Monotonic deny-list version for the index after this mark. + uint64 version = 2; + string error = 3; // Non-empty on error +} + +// ─── FilterDeleted ───────────────────────────────────────────────── + +message FilterDeletedRequest { + // Auth token. Required, Fixed 36 chars (evt_ + 32 hex). + string token = 1 [(buf.validate.field).string = {min_len: 36, max_len: 36}]; + // enVector index the deny-list belongs to. Required. + string index_name = 2 [(buf.validate.field).string.min_len = 1]; + // Candidate item_ids to check. Required, max 10000. + repeated uint64 item_ids = 3 [(buf.validate.field).repeated = { + min_items: 1, + max_items: 10000 + }]; +} + +message FilterDeletedResponse { + // Subset of the requested item_ids that is on the deny-list. + repeated uint64 deleted_item_ids = 1; + // Monotonic deny-list version for the index at read time. + uint64 version = 2; + string error = 3; // Non-empty on error +}