diff --git a/cmd/fox/main.go b/cmd/fox/main.go index 8fd29f8..c28784b 100644 --- a/cmd/fox/main.go +++ b/cmd/fox/main.go @@ -112,6 +112,10 @@ Stack: Set masking up on a branch; mask an account on every branch it does not own (Enterprise) mask rule <[schema.]table.column> [partial|hash|fake|empty] [--path $.a.b] [--off] [--branch b] [--yes] Mask a confirmed finding for masked accounts, after saying what it costs (Enterprise) + mask key [status] [--json] | create | rotate | drop [--yes] + The masking keys: domains that never hash alike, and rotating a key (Enterprise) + mask key use --table [schema.]table [--branch b] [--yes] + Put a table's masks under a key domain (Enterprise) access review [--dormant 90] [--json] [--csv accounts|keys|branches|applications] Who can reach what, with which keys, who may override the guardrails, who has gone quiet, and the applications that assert their users (Enterprise) diff --git a/cmd/fox/maskcmd.go b/cmd/fox/maskcmd.go index 31e7216..9642b5c 100644 --- a/cmd/fox/maskcmd.go +++ b/cmd/fox/maskcmd.go @@ -22,6 +22,9 @@ import ( // fox mask rule <[schema.]table.column> [rule] [--path $.a.b] [--branch b] [--yes] // fox mask rule <[schema.]table.column> --off [--path $.a.b] [--branch b] // fox mask account [--off] [--yes] (Enterprise) +// fox mask key [status] [--json] +// fox mask key create|rotate|drop [--yes] (Enterprise) +// fox mask key use --table [schema.]table [--branch b] [--yes] (Enterprise) // // Reading where masking stands is in every edition; setting it is Enterprise. // Each setup, rule and account change is in the security log. @@ -37,6 +40,9 @@ func maskCmd(args []string) { case "account": maskAccount(args[1:]) return + case "key": + maskKey(args[1:]) + return case "status": args = args[1:] } @@ -250,3 +256,167 @@ func recordMask(kind, b, detail string) { s.Close() } } + +// maskKey is `fox mask key` (step 2): the install's key domains, and rotating +// a key. Reading is in every edition; changing a key is Enterprise and in the +// security log. The keys themselves are never shown. +func maskKey(args []string) { + sub := "status" + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + sub, args = args[0], args[1:] + } + if sub == "status" { + st, err := branch.MaskKeysState() + must(err) + if hasFlag(args, "--json") { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + must(enc.Encode(st)) + return + } + printMaskKeys(st) + return + } + requireFeature(edition.Masking) + ps := positionals(args, "--branch", "--table") + usage := func() { + fmt.Fprintf(os.Stderr, "usage: %[1]s mask key [status] [--json]\n"+ + " %[1]s mask key create|rotate|drop [--yes]\n"+ + " %[1]s mask key use --table [schema.]table [--branch b] [--yes]\n", brand.CLI) + os.Exit(2) + } + if len(ps) != 1 { + usage() + } + domain, yes := ps[0], hasFlag(args, "--yes") + switch sub { + case "create": + must(branch.CheckMaskDomain(domain)) + if !yes { + fmt.Printf("This makes a key domain, %s: a key of its own, 32 random bytes beside the\n"+ + "default key in %s, never read back and never shown.\n\n"+ + "Tables put under it (`%s mask key use %s --table …`) hash apart from every\n"+ + "other domain's, so their masks cannot be joined with them: for data that must\n"+ + "never be matched up, such as two tenants'. Joins work within one domain.\n\n"+ + "Run again with --yes to make it.\n", domain, branch.MaskKeyDir(), brand.CLI, domain) + return + } + must(branch.CreateMaskDomain(domain, cliActor())) + recordMask(auth.EvMaskKey, "", "key domain "+domain+" made") + fmt.Printf("Done: key domain %s made.\n", domain) + case "rotate": + k, ok := branch.MaskKeyFor(domain) + if !ok { + must(fmt.Errorf("the install has no key domain %q (`%s mask key` lists them)", domain, brand.CLI)) + } + if !yes { + fmt.Printf("This rotates the key of domain %s (key %d).\n\n", domain, max(k.Gen, 1)) + fmt.Print(branch.MaskRotateCost(domain)) + st, err := branch.MaskKeysState() + must(err) + for _, u := range st.Keys { + if u.Domain != domain { + continue + } + var keyed []string + for _, t := range u.Tables { + if t.Keyed { + keyed = append(keyed, t.Branch+": "+t.Schema+"."+t.Table) + } + } + fmt.Printf("\nWritten again (%d tables on running branches)\n", len(keyed)) + for _, t := range keyed { + fmt.Printf(" %s\n", t) + } + if u.Pending { + fmt.Printf("\nA rotation of %s was cut short: this carries it on with the key it made.\n", domain) + } + } + if len(st.Stopped) > 0 { + fmt.Printf("\nNot running, so not rotated: %s. Their tables in %s keep masks made\n"+ + "under the old key until `%s mask setup --branch ` once each starts;\n"+ + "`%s mask key` names them.\n", strings.Join(st.Stopped, ", "), domain, brand.CLI, brand.CLI) + } + fmt.Printf("\nRun again with --yes to rotate it.\n") + return + } + must(branch.RotateMaskKey(domain, cliActor(), func(s string) { fmt.Println(" " + s) })) + recordMask(auth.EvMaskKey, "", "key of domain "+domain+" rotated") + fmt.Printf("Done: %s's key is rotated, and the old one deleted.\n", domain) + case "drop": + if !yes { + fmt.Printf("This deletes key domain %s and its key. It is refused while a table on\n"+ + "any branch uses it, or while a branch stopped since it was made, whose tables\n"+ + "cannot be read.\n\n"+ + "Run again with --yes to delete it.\n", domain) + return + } + must(branch.DropMaskDomain(domain, cliActor())) + recordMask(auth.EvMaskKey, "", "key domain "+domain+" deleted") + fmt.Printf("Done: key domain %s deleted.\n", domain) + case "use": + t := optValue(args, "--table") + if t == "" { + usage() + } + schema, table := "public", t + if i := strings.Index(t, "."); i >= 0 { + schema, table = t[:i], t[i+1:] + } + b := maskBranch(args) + if !yes { + fmt.Printf("This puts %s.%s's masks on %s under key domain %s.\n\n"+ + "Only that table's hashed and faked masks are written again, under %s's key;\n"+ + "masked readers read the old ones until it is done. Its masks then join only\n"+ + "with tables in %s.\n\nRun again with --yes to go ahead.\n", schema, table, b, domain, domain, domain) + return + } + must(branch.UseMaskDomain(b, schema, table, domain, cliActor())) + recordMask(auth.EvMaskKey, b, fmt.Sprintf("%s.%s on %s put under key domain %s", schema, table, b, domain)) + fmt.Printf("Done: %s.%s on %s is under key domain %s.\n", schema, table, b, domain) + default: + usage() + } +} + +func printMaskKeys(st branch.MaskKeysStatus) { + if len(st.Keys) == 0 { + fmt.Printf("Masking keys: none. `%s mask setup` makes the install's first.\n", brand.CLI) + return + } + fmt.Printf("Masking keys (%d). The keys are never shown.\n", len(st.Keys)) + for _, k := range st.Keys { + made := k.Made + if made == "" { + made = "before key records were kept" + } + fmt.Printf("\n %s: key %d, made %s", k.Domain, max(k.Gen, 1), made) + if k.By != "" { + fmt.Printf(" by %s", k.By) + } + if k.Rotated != "" { + fmt.Printf("; rotated %s by %s", k.Rotated, k.RotBy) + } + fmt.Println() + if k.Next { + fmt.Printf(" A ROTATION WAS CUT SHORT: `%s mask key rotate %s --yes` finishes it.\n", brand.CLI, k.Domain) + } + for _, t := range k.Tables { + what := "partial or empty only, no key" + if t.Keyed { + what = fmt.Sprintf("under key %d", max(t.Gen, 1)) + } + fmt.Printf(" %s: %s.%s, %s\n", t.Branch, t.Schema, t.Table, what) + } + if len(k.Tables) == 0 { + fmt.Printf(" no table on a running branch\n") + } + for _, t := range k.Stale { + fmt.Printf(" MADE UNDER AN OLDER KEY: %s: %s.%s; `%s mask setup --branch %s` makes them again\n", + t.Branch, t.Schema, t.Table, brand.CLI, t.Branch) + } + } + if len(st.Stopped) > 0 { + fmt.Printf("\nNot running, so not read: %s\n", strings.Join(st.Stopped, ", ")) + } +} diff --git a/docs/FOX_Checklist.html b/docs/FOX_Checklist.html index b10dd1d..a5e0a7a 100644 --- a/docs/FOX_Checklist.html +++ b/docs/FOX_Checklist.html @@ -771,11 +771,11 @@

OPersonal data & complianceO3Masking personal data (point 4.6 and F5, Enterprise; docs/FOX_F5_Masking_Design.md)
  • Gate 0 prototype (make integration-f5, 57/57 on PostgreSQL 18 and 16): the keyed hash in bb_identity 0.2, its key a file the server reads; a masked branch on a ZFS clone left with no real value in its files; the costs measured
  • Step 1: fox mask setup|rule|account, four API routes and the console’s Masking page; rules (partial, keyed hash, fake, empty) only on confirmed findings, Aadhaar never hashed or faked; masks kept in shadow tables by statement triggers; masked accounts read masks and ordinary tables, never the real marked table, the hash or the key, and cannot write to marked tables; admins and a branch’s owner see real values; the cost said before a rule is set
  • -
  • Step 2: key domains and rotation
  • +
  • Step 2: fox mask key [status|create|rotate|drop|use], two API routes and a Keys panel on the Masking page; key domains whose masks hash apart; a table’s masks put under one; a key rotated on every running branch while masked readers read the old masks until each table is done and writes pause only for a moment, then the old key deleted; a rotation cut short carries on when run again; a branch stopped through a rotation named, and its masks made again by setup; a domain in use, or perhaps in use on a branch stopped since it was made, is not deleted
  • Step 3: unmask, the audited lookup, unmask rights in the access review, reads audited
  • Step 4: every access path tested (COPY, functions, EXPLAIN, statistics, logs)
  • Step 5: masked branches, closed until masked and verified
  • -
  • One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights
enterprise/masking unit; internal/branch mask_test.go; VM §11ai; masking.spec.ts +
  • One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a domain’s key is the install’s, shared by every branch, so a branch stopped through a rotation keeps its old masks until set up again
  • enterprise/masking unit; internal/branch mask_test.go, maskkey_test.go; VM §11ai, §11aj; masking.spec.ts
    FOX_Checklist · area O
    diff --git a/docs/FOX_Checklist.pdf b/docs/FOX_Checklist.pdf index 543a53d..fbf606f 100644 Binary files a/docs/FOX_Checklist.pdf and b/docs/FOX_Checklist.pdf differ diff --git a/docs/FOX_Feature_Implemented.html b/docs/FOX_Feature_Implemented.html index 6f8306f..153f6d7 100644 --- a/docs/FOX_Feature_Implemented.html +++ b/docs/FOX_Feature_Implemented.html @@ -119,6 +119,7 @@

    Change log

    Newest first. Every change to FoxByte adds a row here.

    + @@ -1693,12 +1694,12 @@

    Test matrix

    O3

    Masking personal data

    Partial
    What it does
    An admin masks accounts; a masked account then reads, on every branch it does not own, the masks of the columns a reviewer confirmed as personal data, and everything else as it is. Each rule is on a confirmed finding (point 4.6), a column or a JSON path in it: partial (all but the last four characters as X), hash (a keyed HMAC-SHA-256, the same value the same mask, so filters, grouping and joins still work), fake (a made-up value of its kind, the same for the same value) or empty. Without a rule named, the category’s default: partial for Aadhaar, PAN, cards, mobiles, GSTIN and IFSC; hash for email; else empty. Aadhaar is never hashed or faked: a hash applications join on would be a reference key, which UIDAI forbids. The cost is said before a rule is set.
    -
    How it works
    Masks are made on write, not read: for a hash, a fake or a JSON path, a shadow table bb_mask.<schema>__<table> holds each row’s masks, kept by statement triggers with transition tables (inserts, changes, a moved key, deletes, TRUNCATE) owned by bb_masker, which nobody logs in as; a view with the table’s own name in bb_mask joins it to the table, and computes partial and empty masks itself, so a table with only those has no shadow and no write cost. Rows already there are filled in committed batches of 10,000 without holding up writers. A masked account’s role is in db_masked rather than db_client, with search_path = bb_mask, public and log_min_error_statement = panic (superuser-only, so it cannot undo them); db_masked has no right on a marked table, its shadow, the hash function or the key. The key is 32 random bytes in the install’s state directory, owned by the database server’s user and readable by nobody else, mounted read-only at /run/bb-mask; bb_identity 0.2’s bbid_mask_hmac reads it, and FoxByte never reads it back. Masked accounts are kept install-wide; the role is made on each branch when the account is used, and changed at once on running ones, with its open sessions ended. Every change is recorded in the Blackbox under the person who asked.
    -
    Who
    Admins only: they set masking up, set rules and mask accounts. An admin is never masked (they can change the masks), and a branch’s owner sees real values on it. Reading where masking stands is in every edition; setting it is Enterprise (masking). Every setup, rule and account change is in the security log (mask.setup, mask.rule, mask.account).
    -
    How to use
    fox mask [status] [--branch b] [--json] · fox mask setup [--branch b] [--yes] (makes the key; the branch restarts to mount it) · fox mask rule <[schema.]table.column> [partial|hash|fake|empty] [--path $.a.b] [--branch b] [--yes] and --off · fox mask account <email> [--off] [--yes] · GET /api/masking, POST /api/masking/setup|rule|account · the console’s Masking page (Compliance): set up, the rules and whether each table’s masks are made, the confirmed findings without a rule with the rules each may have, and the masked accounts. Each change is shown, with its cost, before it is made.
    -
    How to test
    Unit: enterprise/masking/sql_test.go (the shadow, triggers, fill and view for every rule; no shadow for partial alone; rights given back with no rules; what cannot be masked refused, saying why; names outside public), internal/branch/mask_test.go (Aadhaar refused a hash; the defaults; the masked and unmasked role; the accounts’ file and its exemptions; the licence; the mount; the cost). Integration §11ai. Playwright web/tests/masking.spec.ts.
    -
    Status & limits
    Partial: Gate 1 step 1 of the design. Next: key domains and rotation (step 2); unmask, the audited lookup and unmask rights in the access review (step 3); every access path tested (step 4); masked branches (step 5). A table needs a one-column primary key; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a schema made after setup needs setup run again. Measured cost on a table with a hash, fake or JSON mask: a fifth to a third fewer single-row writes, a 10,000-row UPDATE about 3.6 times as long; masked reads about a quarter slower; documents over 64 KB with a JSON path much more (design, decision 9).
    -
    Added in
    feat/f5-mask-step1.
    +
    How it works
    Masks are made on write, not read: for a hash, a fake or a JSON path, a shadow table bb_mask.<schema>__<table> holds each row’s masks, kept by statement triggers with transition tables (inserts, changes, a moved key, deletes, TRUNCATE) owned by bb_masker, which nobody logs in as; a view with the table’s own name in bb_mask joins it to the table, and computes partial and empty masks itself, so a table with only those has no shadow and no write cost. Rows already there are filled in committed batches of 10,000 without holding up writers. A masked account’s role is in db_masked rather than db_client, with search_path = bb_mask, public and log_min_error_statement = panic (superuser-only, so it cannot undo them); db_masked has no right on a marked table, its shadow, the hash function or the key. The key is 32 random bytes in the install’s state directory, owned by the database server’s user and readable by nobody else, mounted read-only at /run/bb-mask; bb_identity 0.2’s bbid_mask_hmac reads it, and FoxByte never reads it back. Key domains (step 2): the install starts with one, default, so a value hashes alike in every table and joins work; a named domain has a key of its own, <domain>.key, so tables under it hash apart from every other domain’s and cannot be joined with them (for data that must never be matched up, such as two tenants’). Each branch keeps its tables’ domain and the key their masks were made under in bb.mask_tables; what is kept about the keys (domain, key number, when made and rotated, by whom) is in mask-keys.json in the state directory, never a key. Rotation makes the new key beside the old as <domain>--next.key (a name a person cannot give a domain), and on each running branch, for each table in the domain with keyed masks: a second shadow kept by the same triggers under the new key beside the old, filled in committed batches, then swapped in, in one transaction, with the view replaced in place (its grants kept) and the old shadow dropped, so masked readers read the old masks until the table is done; a table’s writes pause for a moment as it starts and as it is swapped over (a lock that waits for the writes already running, so none runs triggers changed under it, bounded and tried again behind a long transaction), and reads never wait. Then the new key is copied over the domain’s file, which deletes the old key, the triggers write under the domain’s name again, and the next key is removed. A rotation cut short carries on with the key it made; a branch started during one has its masks made again; one stopped through it keeps masks made under the old key, is named, and is made again by fox mask setup --branch. Masked accounts are kept install-wide; the role is made on each branch when the account is used, and changed at once on running ones, with its open sessions ended. Every change is recorded in the Blackbox under the person who asked.
    +
    Who
    Admins only: they set masking up, set rules and mask accounts. An admin is never masked (they can change the masks), and a branch’s owner sees real values on it. Reading where masking stands is in every edition; setting it is Enterprise (masking). Every setup, rule, account and key change is in the security log (mask.setup, mask.rule, mask.account, mask.key).
    +
    How to use
    fox mask [status] [--branch b] [--json] · fox mask setup [--branch b] [--yes] (makes the key; the branch restarts to mount it) · fox mask rule <[schema.]table.column> [partial|hash|fake|empty] [--path $.a.b] [--branch b] [--yes] and --off · fox mask account <email> [--off] [--yes] · fox mask key [status] [--json], fox mask key create|rotate|drop <domain> [--yes], fox mask key use <domain> --table [schema.]table [--branch b] [--yes] · GET /api/masking, GET /api/masking/keys, POST /api/masking/setup|rule|account|key · the console’s Masking page (Compliance): set up, the rules and whether each table’s masks are made, the confirmed findings without a rule with the rules each may have, the masked accounts, and the keys (each domain, its key number and tables, rotate and delete, a new domain, a table moved). Each change is shown, with its cost, before it is made.
    +
    How to test
    Unit: enterprise/masking/sql_test.go (the shadow, triggers, fill and view for every rule; no shadow for partial alone; rights given back with no rules; what cannot be masked refused, saying why; names outside public), internal/branch/mask_test.go (Aadhaar refused a hash; the defaults; the masked and unmasked role; the accounts’ file and its exemptions; the licence; the mount; the cost), internal/branch/maskkey_test.go (domain names, the records, what a rotation says, the licence) and, in sql_test.go, a table under a domain, rekeying, and repointing. Integration §11ai, §11aj (domains hash apart; a table moved and back; a rotation while the application writes and a masked account reads, every mask under the new key, the old key’s file replaced and the next one removed, nothing left over; a stopped branch named and made again; a domain in use kept; the API; the security log). Playwright web/tests/masking.spec.ts.
    +
    Status & limits
    Partial: Gate 1 steps 1 and 2 of the design. Next: unmask, the audited lookup and unmask rights in the access review (step 3); every access path tested (step 4); masked branches (step 5). A table needs a one-column primary key; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a schema made after setup needs setup run again; a domain is deleted only when no table uses it and no branch has stopped since it was made, whose tables cannot be read. Rotating costs about 21 seconds a million rows, and a table’s writes about twice the usual masking while its masks are written again. Measured cost on a table with a hash, fake or JSON mask: a fifth to a third fewer single-row writes, a 10,000-row UPDATE about 3.6 times as long; masked reads about a quarter slower; documents over 64 KB with a JSON path much more (design, decision 9).
    +
    Added in
    feat/f5-mask-step1; key domains and rotation in feat/f5-mask-step2.
    FOX_Feature Implemented · area O
    diff --git a/docs/FOX_Feature_Implemented.pdf b/docs/FOX_Feature_Implemented.pdf index ee20dbb..0b317ca 100644 Binary files a/docs/FOX_Feature_Implemented.pdf and b/docs/FOX_Feature_Implemented.pdf differ diff --git a/enterprise/masking/keys.go b/enterprise/masking/keys.go new file mode 100644 index 0000000..f647495 --- /dev/null +++ b/enterprise/masking/keys.go @@ -0,0 +1,209 @@ +//go:build enterprise + +// SPDX-License-Identifier: LicenseRef-FoxByte-Enterprise-1.0 + +package masking + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/thefoxbyte/foxbyte/internal/branch" + "github.com/thefoxbyte/foxbyte/internal/brand" +) + +// Key domains and rotation (Gate 1, step 2; design, section 8). The key files +// are the core's (internal/branch/maskkey.go); moving masks between keys is +// here. + +// rekey moves one table's keyed masks from one key to another, masked readers +// reading the old ones until the swap, then records the domain and key. +func rekey(b, schema, table, from, to, domain string, gen int, actor string) error { + cols, err := branch.MaskColumns(b, schema, table) + if err != nil { + return err + } + rules, err := branch.MaskRulesOn(b) + if err != nil { + return err + } + rk, keyed, err := rekeyTable(schema, table, cols, rulesFor(rules, schema, table), from, to) + if err != nil { + return err + } + if !keyed { + return branch.RunMaskSQL(b, actor, []string{tableKeySQL(schema, table, domain, gen)}, false) + } + if err := runHoldingWrites(b, actor, rk.Start, true); err != nil { + return fmt.Errorf("starting %s.%s's new masks: %w", schema, table, err) + } + if err := branch.RunMaskScript(b, rk.Fill); err != nil { + return fmt.Errorf("writing %s.%s's new masks: %w (masked readers still read the old ones; run it again)", schema, table, err) + } + if err := runHoldingWrites(b, actor, append(rk.Swap, tableKeySQL(schema, table, domain, gen)), true); err != nil { + return fmt.Errorf("swapping in %s.%s's new masks: %w (masked readers still read the old ones; run it again)", schema, table, err) + } + return nil +} + +// runHoldingWrites runs statements that begin with writesWait, again while +// the wait times out behind a long transaction, for up to a minute. +func runHoldingWrites(b, actor string, stmts []string, replace bool) error { + var err error + for try := 0; try < 15; try++ { + if err = branch.RunMaskSQL(b, actor, stmts, replace); err == nil || !strings.Contains(err.Error(), "lock timeout") { + return err + } + time.Sleep(time.Second) + } + return fmt.Errorf("a transaction on the table kept its writes from pausing for a minute: %w (run it again)", err) +} + +// useDomain puts a table's masks on a branch under another domain: only that +// table's masks are written again, and masked readers read the old ones until +// they are. +func useDomain(b, schema, table, domain, actor string) error { + if !hasSetup(b) { + return fmt.Errorf("%w: masking is not set up on %s: %s mask setup --branch %s", branch.ErrInvalidRequest, b, brand.CLI, b) + } + if !branch.MaskKeyFileExists(domain) { + return fmt.Errorf("%w: the key of domain %q is not on this install", branch.ErrInvalidRequest, domain) + } + cur, err := tableDomain(b, schema, table) + if err != nil { + return err + } + if cur == domain { + return nil + } + k, _ := branch.MaskKeyFor(domain) + return rekey(b, schema, table, cur, domain, domain, max(k.Gen, 1), actor) +} + +// rotate makes a domain's new key and moves every running branch's tables in +// the domain to it, then puts it in the domain's file, which deletes the old +// key. Run again after being cut short, it carries on. +func rotate(domain, actor string, say func(string)) error { + k, _ := branch.MaskKeyFor(domain) + gen := max(k.Gen, 1) + if !k.Pending { + if err := branch.MakeNextMaskKey(domain, false); err != nil { + return fmt.Errorf("making %s's new key: %w", domain, err) + } + k.Pending = true + if err := branch.RecordMaskKey(k); err != nil { + return err + } + } else if err := branch.MakeNextMaskKey(domain, true); err != nil { + return err + } + next := branch.NextMaskKey(domain) + + before, err := branch.RunningBranches() + if err != nil { + return err + } + for _, b := range before { + if !hasSetup(b) { + continue + } + if err := ensureKeysTable(b); err != nil { + return err + } + ts, err := branch.MaskTableKeys(b) + if err != nil { + return err + } + for _, t := range ts { + // Done before a cut-short run stopped: under the next key already. + if t.Domain != domain || !t.Keyed || t.Gen > gen { + continue + } + if err := rekey(b, t.Schema, t.Table, domain, next, domain, gen+1, actor); err != nil { + return fmt.Errorf("on %s: %w", b, err) + } + say(fmt.Sprintf("%s: %s.%s written again under the new key", b, t.Schema, t.Table)) + } + } + + // The new key into the domain's own file: the old key is gone, and both + // names hold the new one until the triggers are pointed back at the + // domain's name. + if err := branch.PromoteMaskKey(domain); err != nil { + return fmt.Errorf("putting %s's new key in place: %w", domain, err) + } + after, err := branch.RunningBranches() + if err != nil { + return err + } + for _, b := range after { + if !hasSetup(b) { + continue + } + if !slices.Contains(before, b) { + // Started, or made, during the rotation: its masks are made again + // under the keys as they are now. + if err := setup(b, actor); err != nil { + return fmt.Errorf("on %s, started during the rotation: %w", b, err) + } + say(fmt.Sprintf("%s: started during the rotation, its masks made again", b)) + continue + } + if err := repoint(b, domain, gen+1, actor); err != nil { + return fmt.Errorf("on %s: %w", b, err) + } + } + var gone []string + for _, b := range before { + if !slices.Contains(after, b) { + gone = append(gone, b) + } + } + if len(gone) == 0 { + if err := branch.RemoveMaskKey(next); err != nil { + return err + } + } else { + // Their triggers may still write under the next key's name, which now + // holds the domain's key: kept, so their writes keep working. + say(fmt.Sprintf("%v stopped during the rotation: run `%s mask setup --branch ` once each starts, "+ + "to make its masks again under the new key", gone, brand.CLI)) + } + k.Gen, k.Pending = gen+1, false + k.Rotated, k.RotBy = time.Now().UTC().Format(time.RFC3339), actor + return branch.RecordMaskKey(k) +} + +// repoint has the triggers of a branch's tables rotated in a domain write +// under the domain's name again, which now holds the new key. +func repoint(b, domain string, gen int, actor string) error { + ts, err := branch.MaskTableKeys(b) + if err != nil { + return err + } + rules, err := branch.MaskRulesOn(b) + if err != nil { + return err + } + for _, t := range ts { + if t.Domain != domain || !t.Keyed || t.Gen != gen { + continue + } + cols, err := branch.MaskColumns(b, t.Schema, t.Table) + if err != nil { + return err + } + stmts, err := repointTable(t.Schema, t.Table, cols, rulesFor(rules, t.Schema, t.Table), domain) + if err != nil { + return err + } + if len(stmts) > 0 { + if err := runHoldingWrites(b, actor, stmts, false); err != nil { + return err + } + } + } + return nil +} diff --git a/enterprise/masking/masking.go b/enterprise/masking/masking.go index 9231ccb..a4cb5c4 100644 --- a/enterprise/masking/masking.go +++ b/enterprise/masking/masking.go @@ -14,14 +14,15 @@ import ( ) func init() { - branch.SetMasker(&branch.Masker{Setup: setup, SetRule: setRule, DropRule: dropRule, MaskTables: maskTables}) + branch.SetMasker(&branch.Masker{Setup: setup, SetRule: setRule, DropRule: dropRule, MaskTables: maskTables, + Rotate: rotate, UseDomain: useDomain}) } // setup makes the install's default key if it has none, then the branch's // roles, schema, rules table and a masked reader's rights. It runs again // safely: a table already masked is masked again from its rules. func setup(b, actor string) error { - if err := branch.EnsureMaskKey(keyDomain); err != nil { + if err := branch.EnsureMaskKey(branch.DefaultMaskDomain); err != nil { return fmt.Errorf("making the masking key: %w", err) } if err := branch.RunMaskSQL(b, actor, setupSQL(), false); err != nil { @@ -99,7 +100,7 @@ func setRule(b string, r branch.MaskRule) error { if err != nil { return err } - if _, err := maskTable(r.Schema, r.Table, cols, rulesFor(rules, r.Schema, r.Table)); err != nil { + if _, err := maskTable(r.Schema, r.Table, cols, rulesFor(rules, r.Schema, r.Table), branch.DefaultMaskDomain); err != nil { return err } if err := branch.RunMaskSQL(b, r.By, []string{fmt.Sprintf(`INSERT INTO bb.mask_rules (schema_name, table_name, column_name, json_path, rule, category, set_by) @@ -135,10 +136,17 @@ func remask(b, schema, table, actor string, all []branch.MaskRule) error { if err != nil { return err } - m, err := maskTable(schema, table, cols, rulesFor(all, schema, table)) + domain, err := tableDomain(b, schema, table) if err != nil { return err } + m, err := maskTable(schema, table, cols, rulesFor(all, schema, table), domain) + if err != nil { + return err + } + // Made under the domain's key now. + k, _ := branch.MaskKeyFor(domain) + m.View = append(m.View, tableKeySQL(schema, table, domain, max(k.Gen, 1))) if err := branch.RunMaskSQL(b, actor, append(m.Drop, m.Build...), true); err != nil { return fmt.Errorf("making %s.%s's masks: %w", schema, table, err) } @@ -176,6 +184,35 @@ func rulesFor(rules []branch.MaskRule, schema, table string) []branch.MaskRule { return out } +// tableKeySQL records a table's domain and the key its masks are made under. +func tableKeySQL(schema, table, domain string, gen int) string { + return fmt.Sprintf(`INSERT INTO bb.mask_tables (schema_name, table_name, key_domain, key_gen) VALUES (%s, %s, %s, %d) + ON CONFLICT (schema_name, table_name) DO UPDATE SET key_domain = EXCLUDED.key_domain, key_gen = EXCLUDED.key_gen`, + lit(schema), lit(table), lit(domain), gen) +} + +// tableDomain is the domain a table's masks are under: the default unless one +// was chosen for it. +func tableDomain(b, schema, table string) (string, error) { + if err := ensureKeysTable(b); err != nil { + return "", err + } + out, err := branch.QuerySQL(b, fmt.Sprintf(`SELECT coalesce((SELECT key_domain FROM bb.mask_tables WHERE schema_name = %s AND table_name = %s), %s)`, + lit(schema), lit(table), lit(branch.DefaultMaskDomain))) + if err != nil { + return "", fmt.Errorf("reading %s.%s's key domain on %s: %w (%s)", schema, table, b, err, strings.TrimSpace(out)) + } + return strings.TrimSpace(out), nil +} + +// ensureKeysTable adds the table of keys to a branch set up before step 2. +func ensureKeysTable(b string) error { + if ok, err := branch.RelationExists(b, "bb.mask_tables"); err != nil || ok { + return err + } + return branch.RunMaskSQL(b, "system", []string{maskTablesDDL, "REVOKE ALL ON bb.mask_tables FROM PUBLIC"}, false) +} + func hasSetup(b string) bool { out, err := branch.QuerySQL(b, "SELECT to_regclass('bb.mask_rules') IS NOT NULL AND to_regrole('"+branch.MaskedRole+"') IS NOT NULL") return err == nil && strings.TrimSpace(out) == "t" diff --git a/enterprise/masking/sql.go b/enterprise/masking/sql.go index 1430cbb..430123e 100644 --- a/enterprise/masking/sql.go +++ b/enterprise/masking/sql.go @@ -17,8 +17,6 @@ import ( // 6 and 7, as the prototype proved them in section 17), made from its rules and // its columns by pure functions, so the tests can read it. -const keyDomain = "default" - func ident(s string) string { return branch.QuoteIdent(s) } func lit(s string) string { return branch.QuoteLiteral(s) } @@ -27,6 +25,8 @@ func lit(s string) string { return branch.QuoteLiteral(s) } // name reaches it; a table in another schema's view is __
    DateFeature(s)What changedCommit / PR
    6 Nov 2026O3Masking personal data, step 2 (F5 Gate 1, Enterprise): key domains and rotation. fox mask key [status] [--json] lists each key domain, its key number, when it was made and rotated and by whom, the tables under it on running branches and those whose masks were made under an older key; never a key. fox mask key create <domain> [--yes] makes a domain with a key of its own (32 random bytes beside the default, the server’s alone): tables under it hash apart from every other domain’s and cannot be joined with them. fox mask key use <domain> --table [schema.]table [--branch b] [--yes] puts a table’s masks under one, writing only that table’s again. fox mask key rotate <domain> [--yes] says what it does and costs first (about 21 seconds a million rows; every hash changes; no undo), then on every running branch, table by table, keeps a second shadow under the new key beside the old, fills it, and swaps it in with the view replaced in place, so masked readers read the old masks until each table is done; the new key then replaces the old one’s file, which deletes the old key. A rotation cut short carries on when run again; a branch started during one is made again; one stopped through it is named and made again by fox mask setup --branch. fox mask key drop <domain> deletes a domain no table uses; a stopped branch blocks it only if a table was put under the domain there or it was made since the domain was. The console’s Masking page has a Keys panel (make, rotate behind a dialog with the cost, delete, move a table); the API is GET /api/masking/keys and POST /api/masking/key (admins); every change is in the security log (mask.key). Found and fixed by the suite: a write running when a table’s masks were swapped finished with triggers naming a shadow just renamed away, so the start, swap and repoint now hold that table’s writes for a moment (reads go on; bounded, and tried again behind a long transaction). Checked on a throwaway PostgreSQL 18 with 200,000 rows and six writers on new connections: no write refused, every mask under the new key. Tests: integration 811/811 (§11aj new: domains hash apart; a table moved and back; a rotation while the application writes and a masked account reads, every mask under the new key, the old key’s file replaced and the next one removed, nothing left over, the key in no log; a stopped branch named and made again; a domain in use kept, the default never deleted; the API; the security log), v2 403/403, UI 48/48 plus the upgrade test (a domain made, a table moved, its key rotated, a domain in use kept, then deleted), integration-f5 57/57, f1 70/70, f6 71/71, web unit 256/256, Go unit tests in both editions (domain names, the records, which stopped branches may use a domain, the rekey and repoint SQL), vet for macOS, Linux and Windows.feat/f5-mask-step2
    6 Nov 2026O3Masking personal data, step 1 (F5 Gate 1, point 4.6, Enterprise; licence feature masking): rules on confirmed findings, and masked accounts. fox mask setup --branch b [--yes] previews, then makes the key (<state>/mask/default.key, 32 bytes, owned by the server’s user, 0400 in a 0700 folder, mounted read-only at /run/bb-mask; never in a table, a log or a message) and restarts the branch to mount it if it must. fox mask rule <[schema.]table.column> [--path $.a] [--rule hash|fake|partial|empty] [--off] [--yes] masks a column the personal-data scan found and a person confirmed, and says what it costs before it is set (design decision 9: the founders did not decide, so the recommendation stands): nothing on writes for partial or empty, about a fifth to a third fewer single-row writes and a 10,000-row UPDATE about 3.6 times as long for a hash or a fake, more for a JSON path. Aadhaar is offered partial and empty only, never hashed or faked. fox mask account <email> [--off] --yes masks an account: on main and any branch it does not own it reads the masks of marked tables and real values elsewhere, cannot write marked tables, and its failing statements are never logged with their text; an admin cannot be masked, and admins and a branch’s owner always see real values (decisions 2–4). Masks sit in shadow tables kept by statement triggers and are read through views, so nothing is computed at read time. The console’s Masking page sets masking up, sets and takes off rules behind a dialog showing the cost, and masks and unmasks accounts; the API is /api/masking (admins); every change is in the security log. Fixed on the way: an account’s role is found by name when it is unmasked (to_regrole never finds an email), and the uninstall removes the key’s folder although the server’s user owns it. Limits: one-column primary keys only; array or quoted JSON paths refused; masking stays on once set up; a new schema needs setup run again; key domains, rotation, lookup, reads audited and masked branches are steps 2–5. Tests: integration 792/792 (§11ai new: the key’s owner, modes and read-only mount, nothing in the logs; rules refused before confirmation; the cost; Aadhaar’s hash refused; each rule’s mask as a masked account reads it; joins on a hash; the real table, shadow and hash out of reach; writes refused; the application’s writes kept up; admins and owners see real values; rules off and accounts unmasked; the security log), v2 403/403, UI 47/47 plus the upgrade test (a rule set after its cost, Aadhaar’s choices, an account masked and unmasked), integration-f5 57/57, f1 70/70, f6 71/71, web unit 256/256, Go unit tests in both editions, vet for macOS, Linux and Windows.feat/f5-mask-step1
    5 Nov 2026O1, O2, H21Masking’s prototype (F5 Gate 0): the design’s claims, proven on PostgreSQL 18 and 16 (docs/FOX_F5_Masking_Design.md, section 17; make integration-f5, 57/57). bb_identity 0.2 adds bbid_mask_hmac(domain, value): HMAC-SHA-256 under a key domain’s key, a 32-byte file at /run/bb-mask/<domain>.key that must be the server’s own and readable by nobody else, never in a table or a message, read again when the file is replaced; never granted to PUBLIC (an upgrade from 0.1 included). The suite builds the image, and on throwaway containers shows: each rule’s mask (fake, hash, partial, empty, a JSON path); the real table, shadows, hash, fake function and key file out of a masked reader’s reach; filtering, grouping, joins on hashes, EXPLAIN, pg_stats and COPY as designed; no writes to marked tables; the lookup masked, 30 a minute, recorded without the value; the application’s writes, key changes, deletes and TRUNCATE kept up; key domains, a missing or loose key refused, rotation seen by a session that read the old key. It found and fixed: a refused lookup’s value reaching the server log (every masked login now has log_min_error_statement = panic), and a masked branch’s steps needing a statement each; with VACUUM FULL of the table and of pg_statistic, and WAL recycling off across three switches and checkpoints, a masked branch on a ZFS clone has no real value left in its files (a fresh cluster of only masked rows: 0.3 s for 20,000). Measured on PostgreSQL 18: single-row writes to a marked table 21–30% fewer, a 10,000-row UPDATE 5.6× (3.6× with a trigger a statement), indexed reads through the view 25% fewer, 1,000,000 masks filled in 14 s without holding up a writer and rotated in 21 s; over the 10% budget, so the cost is said before a rule is set (design decision 9). No product code yet: Gate 1 follows.feat/f5-mask-proto
    5 Nov 2026O1, O2, H21Masking’s design proposed (F5 and point 7; docs/FOX_F5_Masking_Design.md, with the probes in docs/f5/). Checked on a throwaway PostgreSQL 18.6: a view whose masking function the reader may not EXECUTE fails to read at all, so masks computed in a view make the function an oracle (the attack behind PostgreSQL Anonymizer’s CVE); masks kept in a shadow table by a trigger, read through a view with nothing computed at read time, work for WHERE, GROUP BY, ORDER BY and EXPLAIN while the hash stays out of reach; pg_stats shows nothing for a table a reader has no grant on; a unique violation hides the value from a writer who cannot read it, but not its existence. Proposed: FoxByte’s own masks rather than Anonymizer (also not packaged for PostgreSQL 18 in apt.postgresql.org); masked readers who read masks of marked tables and do not write them; nobody masked until an admin says so, admins and owners always seeing real values; the HMAC key in a file the server reads, never a table, its boundary stated; Aadhaar never hashed; masked branches masked in place, vacuumed, analysed, verified and closed until then. No code: the prototype (Gate 0) follows agreement.docs/f5-masking-design
    . type names struct { base, shadow, view, fn string + next string // the shadow a rotation fills, until it is swapped in + shadowName string // the shadow's name in bb_mask, unquoted } func namesFor(schema, table string) names { @@ -35,10 +35,12 @@ func namesFor(schema, table string) names { view = schema + "__" + table } return names{ - base: ident(schema) + "." + ident(table), - shadow: branch.MaskSchema + "." + ident(schema+"__"+table), - view: branch.MaskSchema + "." + ident(view), - fn: branch.MaskSchema + "." + ident("keep__"+schema+"__"+table), + base: ident(schema) + "." + ident(table), + shadow: branch.MaskSchema + "." + ident(schema+"__"+table), + view: branch.MaskSchema + "." + ident(view), + fn: branch.MaskSchema + "." + ident("keep__"+schema+"__"+table), + next: branch.MaskSchema + "." + ident(schema+"__"+table+"__next"), + shadowName: schema + "__" + table, } } @@ -53,9 +55,10 @@ func pathKeys(p string) ([]string, error) { return strings.Split(strings.TrimPrefix(p, "$."), "."), nil } -// hash is the keyed hash of an expression as text. -func hash(expr string) string { - return fmt.Sprintf("bb_f1.bbid_mask_hmac(%s, (%s)::text)", lit(keyDomain), expr) +// hash is the keyed hash of an expression as text, under a key (a domain's, +// or its next one's during a rotation). +func hash(key, expr string) string { + return fmt.Sprintf("bb_f1.bbid_mask_hmac(%s, (%s)::text)", lit(key), expr) } // partial keeps the last four characters, every other one an X, so the mask @@ -65,12 +68,12 @@ func partial(expr string) string { } // scalar is a rule applied to a text value. -func scalar(rule, category, expr string) string { +func scalar(key, rule, category, expr string) string { switch rule { case branch.MaskHash: - return hash(expr) + return hash(key, expr) case branch.MaskFake: - return fmt.Sprintf("%s.fake(%s, %s)", branch.MaskSchema, lit(strings.ToLower(category)), hash(expr)) + return fmt.Sprintf("%s.fake(%s, %s)", branch.MaskSchema, lit(strings.ToLower(category)), hash(key, expr)) case branch.MaskPartial: return partial(expr) } @@ -91,10 +94,22 @@ type shadowCol struct { name, typ, expr string // expr over the row alias n } -// maskTable makes it, from the table's columns and its rules. A table with no -// rules gets only Drop and its rights back. -func maskTable(schema, table string, cols []branch.MaskColumn, rules []branch.MaskRule) (tableMask, error) { - nm := namesFor(schema, table) +// plan is a table's masks under one key: what the shadow keeps, and how. +type plan struct { + nm names + pk branch.MaskColumn + cols []branch.MaskColumn + own map[string]branch.MaskRule + shadow []shadowCol +} + +// planTable checks a table's rules against its columns and works out its +// masks under a key. A table with no rules has no plan. +func planTable(schema, table string, cols []branch.MaskColumn, rules []branch.MaskRule, key string) (*plan, error) { + if len(rules) == 0 { + return nil, nil + } + p := &plan{nm: namesFor(schema, table), cols: cols, own: map[string]branch.MaskRule{}} var keys []branch.MaskColumn byName := map[string]branch.MaskColumn{} for _, c := range cols { @@ -103,144 +118,150 @@ func maskTable(schema, table string, cols []branch.MaskColumn, rules []branch.Ma keys = append(keys, c) } } - var m tableMask - m.Drop = []string{ - "REVOKE ALL ON " + nm.base + " FROM " + branch.MaskedRole, - "DROP VIEW IF EXISTS " + nm.view, - "DROP TRIGGER IF EXISTS bb_mask_i ON " + nm.base, - "DROP TRIGGER IF EXISTS bb_mask_u ON " + nm.base, - "DROP TRIGGER IF EXISTS bb_mask_d ON " + nm.base, - "DROP TRIGGER IF EXISTS bb_mask_t ON " + nm.base, - "DROP FUNCTION IF EXISTS " + nm.fn + "()", - "DROP TABLE IF EXISTS " + nm.shadow, - } - if len(rules) == 0 { - m.Drop = append(m.Drop, "GRANT SELECT, INSERT, UPDATE, DELETE ON "+nm.base+" TO "+branch.MaskedRole) - return m, nil - } if len(keys) != 1 { - return m, fmt.Errorf("%w: %s.%s has %s: masks are kept by a one-column primary key", branch.ErrInvalidRequest, schema, table, + return nil, fmt.Errorf("%w: %s.%s has %s: masks are kept by a one-column primary key", branch.ErrInvalidRequest, schema, table, map[bool]string{true: "no primary key", false: "a primary key of several columns"}[len(keys) == 0]) } - pk := keys[0] + p.pk = keys[0] // The rules by column: the column's own, and its JSON paths. - own := map[string]branch.MaskRule{} paths := map[string][]branch.MaskRule{} for _, r := range rules { c, ok := byName[r.Column] if !ok { - return m, fmt.Errorf("%w: %s.%s has no column %q", branch.ErrInvalidRequest, schema, table, r.Column) + return nil, fmt.Errorf("%w: %s.%s has no column %q", branch.ErrInvalidRequest, schema, table, r.Column) } if c.Key { - return m, fmt.Errorf("%w: %s is the table's key, which the masks are kept by: it cannot be masked", branch.ErrInvalidRequest, r.Where()) + return nil, fmt.Errorf("%w: %s is the table's key, which the masks are kept by: it cannot be masked", branch.ErrInvalidRequest, r.Where()) } if r.Path == "" { - own[r.Column] = r + p.own[r.Column] = r continue } if c.Type != "jsonb" { - return m, fmt.Errorf("%w: a JSON path is masked in a jsonb column, and %s is %s", branch.ErrInvalidRequest, r.Column, c.Type) + return nil, fmt.Errorf("%w: a JSON path is masked in a jsonb column, and %s is %s", branch.ErrInvalidRequest, r.Column, c.Type) } if _, err := pathKeys(r.Path); err != nil { - return m, err + return nil, err } paths[r.Column] = append(paths[r.Column], r) } - var shadow []shadowCol for _, c := range cols { - if r, ok := own[c.Name]; ok && (r.Rule == branch.MaskHash || r.Rule == branch.MaskFake) { - shadow = append(shadow, shadowCol{c.Name, "text", scalar(r.Rule, r.Category, "n."+ident(c.Name))}) + if r, ok := p.own[c.Name]; ok && (r.Rule == branch.MaskHash || r.Rule == branch.MaskFake) { + p.shadow = append(p.shadow, shadowCol{c.Name, "text", scalar(key, r.Rule, r.Category, "n."+ident(c.Name))}) continue } if ps, ok := paths[c.Name]; ok { - if _, whole := own[c.Name]; whole { - return m, fmt.Errorf("%w: %s has a rule of its own and on its paths: take one off", branch.ErrInvalidRequest, c.Name) + if _, whole := p.own[c.Name]; whole { + return nil, fmt.Errorf("%w: %s has a rule of its own and on its paths: take one off", branch.ErrInvalidRequest, c.Name) } sort.Slice(ps, func(i, j int) bool { return ps[i].Path < ps[j].Path }) doc := "n." + ident(c.Name) for _, r := range ps { ks, _ := pathKeys(r.Path) arr := "'{" + strings.Join(ks, ",") + "}'" - val := fmt.Sprintf("to_jsonb(%s)", scalar(r.Rule, r.Category, "n."+ident(c.Name)+" #>> "+arr)) + val := fmt.Sprintf("to_jsonb(%s)", scalar(key, r.Rule, r.Category, "n."+ident(c.Name)+" #>> "+arr)) if r.Rule == branch.MaskEmpty { val = "'null'::jsonb" } doc = fmt.Sprintf("CASE WHEN n.%[1]s #> %[2]s IS NULL THEN %[3]s ELSE jsonb_set(%[3]s, %[2]s, %[4]s) END", ident(c.Name), arr, doc, val) } - shadow = append(shadow, shadowCol{c.Name, "jsonb", doc}) + p.shadow = append(p.shadow, shadowCol{c.Name, "jsonb", doc}) } } + return p, nil +} - if len(shadow) == 0 { - // Only partial and empty: no secret, so nothing is kept, and writes - // to the table cost nothing more. The view computes the masks. - m.View = viewSQL(nm, cols, own, nil, "") - return m, nil - } +// k is the key column, quoted. +func (p *plan) k() string { return ident(p.pk.Name) } - // The shadow: the key, and the masks kept. - defs := []string{ident(pk.Name) + " " + pk.Type + " PRIMARY KEY"} - var colNames, exprs, sets []string - for _, s := range shadow { +// shadowDDL makes a shadow by the name given, owned by bb_masker, indexed on +// its hashes. +func (p *plan) shadowDDL(shadow string) []string { + defs := []string{p.k() + " " + p.pk.Type + " PRIMARY KEY"} + for _, s := range p.shadow { defs = append(defs, ident(s.name)+" "+s.typ) - colNames = append(colNames, ident(s.name)) - exprs = append(exprs, s.expr) - sets = append(sets, ident(s.name)+" = EXCLUDED."+ident(s.name)) } - k := ident(pk.Name) - m.Build = []string{ - "CREATE TABLE " + nm.shadow + " (" + strings.Join(defs, ", ") + ")", - "ALTER TABLE " + nm.shadow + " OWNER TO " + branch.MaskerRole, - "GRANT SELECT ON " + nm.base + " TO " + branch.MaskerRole, + out := []string{ + "CREATE TABLE " + shadow + " (" + strings.Join(defs, ", ") + ")", + "ALTER TABLE " + shadow + " OWNER TO " + branch.MaskerRole, } - for _, s := range shadow { + for _, s := range p.shadow { if s.typ == "text" { - m.Build = append(m.Build, fmt.Sprintf("CREATE INDEX ON %s (%s)", nm.shadow, ident(s.name))) + out = append(out, fmt.Sprintf("CREATE INDEX ON %s (%s)", shadow, ident(s.name))) } } - upsert := func(from string) string { - cols, vals, set := "", "", "" - if len(colNames) > 0 { - cols, vals = ", "+strings.Join(colNames, ", "), ", "+strings.Join(exprs, ", ") - set = " ON CONFLICT (" + k + ") DO UPDATE SET " + strings.Join(sets, ", ") - } else { - set = " ON CONFLICT (" + k + ") DO NOTHING" + return out +} + +// upsert writes the masks of the rows in from into a shadow; with keep, a +// row already there is left as it is. +func (p *plan) upsert(shadow, from string, keep bool) string { + var names, exprs, sets []string + for _, s := range p.shadow { + names = append(names, ident(s.name)) + exprs = append(exprs, s.expr) + sets = append(sets, ident(s.name)+" = EXCLUDED."+ident(s.name)) + } + k := p.k() + cols, vals, conflict := "", "", " ON CONFLICT ("+k+") DO NOTHING" + if len(names) > 0 { + cols, vals = ", "+strings.Join(names, ", "), ", "+strings.Join(exprs, ", ") + if !keep { + conflict = " ON CONFLICT (" + k + ") DO UPDATE SET " + strings.Join(sets, ", ") } - return fmt.Sprintf("INSERT INTO %s (%s%s) SELECT n.%s%s FROM %s%s", nm.shadow, k, cols, k, vals, from, set) } - // One trigger a statement, from its transition tables (decision 9, and - // section 17: 198 ms rather than 307 for a 10,000-row update). - m.Build = append(m.Build, - fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $bbmask$ + return fmt.Sprintf("INSERT INTO %s (%s%s) SELECT n.%s%s FROM %s%s", shadow, k, cols, k, vals, from, conflict) +} + +// target is a shadow a trigger keeps, under one plan's key. +type target struct { + shadow string + p *plan +} + +// keepFn is the function the triggers run: one call a statement, from its +// transition tables (decision 9, and section 17: 198 ms rather than 307 for a +// 10,000-row update), keeping each target. Two during a rotation. +func keepFn(fn string, targets []target) []string { + k := targets[0].p.k() + var trunc, del, moved, ups []string + for _, t := range targets { + trunc = append(trunc, t.shadow) + del = append(del, fmt.Sprintf("DELETE FROM %s s USING o WHERE s.%[2]s = o.%[2]s", t.shadow, k)) + moved = append(moved, fmt.Sprintf("DELETE FROM %s s USING o WHERE s.%[2]s = o.%[2]s AND NOT EXISTS (SELECT 1 FROM n WHERE n.%[2]s = o.%[2]s)", t.shadow, k)) + ups = append(ups, t.p.upsert(t.shadow, "n", false)) + } + return []string{ + fmt.Sprintf(`CREATE OR REPLACE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $bbmask$ BEGIN IF TG_OP = 'TRUNCATE' THEN - TRUNCATE %[2]s; + TRUNCATE %s; RETURN NULL; END IF; IF TG_OP = 'DELETE' THEN - DELETE FROM %[2]s s USING o WHERE s.%[3]s = o.%[3]s; + %s; RETURN NULL; END IF; IF TG_OP = 'UPDATE' THEN - DELETE FROM %[2]s s USING o WHERE s.%[3]s = o.%[3]s AND NOT EXISTS (SELECT 1 FROM n WHERE n.%[3]s = o.%[3]s); + %s; END IF; - %[4]s; + %s; RETURN NULL; -END $bbmask$`, nm.fn, nm.shadow, k, upsert("n")), - "ALTER FUNCTION "+nm.fn+"() OWNER TO "+branch.MaskerRole, - "REVOKE EXECUTE ON FUNCTION "+nm.fn+"() FROM PUBLIC", - fmt.Sprintf("CREATE TRIGGER bb_mask_i AFTER INSERT ON %s REFERENCING NEW TABLE AS n FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), - fmt.Sprintf("CREATE TRIGGER bb_mask_u AFTER UPDATE ON %s REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), - fmt.Sprintf("CREATE TRIGGER bb_mask_d AFTER DELETE ON %s REFERENCING OLD TABLE AS o FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), - fmt.Sprintf("CREATE TRIGGER bb_mask_t AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), - ) +END $bbmask$`, fn, strings.Join(trunc, ", "), strings.Join(del, ";\n "), strings.Join(moved, ";\n "), strings.Join(ups, ";\n ")), + "ALTER FUNCTION " + fn + "() OWNER TO " + branch.MaskerRole, + "REVOKE EXECUTE ON FUNCTION " + fn + "() FROM PUBLIC", + } +} - // The rows already there, 10,000 at a time, each batch committed: a - // writer meanwhile is never held up (section 17: 1,000,000 in 14 s). - m.Fill = fmt.Sprintf(`DO $bbfill$ +// fill writes the masks of the rows already there into a shadow, 10,000 at a +// time, each batch committed: a writer meanwhile is never held up (section +// 17: 1,000,000 in 14 s). A row the triggers wrote first is left as it is. +func (p *plan) fill(shadow string) string { + k := p.k() + return fmt.Sprintf(`DO $bbfill$ DECLARE last %[1]s; nxt %[1]s; BEGIN LOOP @@ -250,16 +271,122 @@ BEGIN last := nxt; COMMIT; END LOOP; -END $bbfill$;`, pk.Type, k, nm.base, - strings.Replace(upsert(nm.base+" n WHERE (last IS NULL OR n."+k+" > last) AND n."+k+" <= nxt"), "DO UPDATE SET "+strings.Join(sets, ", "), "DO NOTHING", 1)) +END $bbfill$;`, p.pk.Type, k, p.nm.base, p.upsert(shadow, p.nm.base+" n WHERE (last IS NULL OR n."+k+" > last) AND n."+k+" <= nxt", true)) +} - m.View = viewSQL(nm, cols, own, shadow, k) +// maskTable makes a table's masks under a key, from its columns and its +// rules. A table with no rules gets only Drop and its rights back. +func maskTable(schema, table string, cols []branch.MaskColumn, rules []branch.MaskRule, key string) (tableMask, error) { + nm := namesFor(schema, table) + var m tableMask + m.Drop = []string{ + "REVOKE ALL ON " + nm.base + " FROM " + branch.MaskedRole, + "DROP VIEW IF EXISTS " + nm.view, + "DROP TRIGGER IF EXISTS bb_mask_i ON " + nm.base, + "DROP TRIGGER IF EXISTS bb_mask_u ON " + nm.base, + "DROP TRIGGER IF EXISTS bb_mask_d ON " + nm.base, + "DROP TRIGGER IF EXISTS bb_mask_t ON " + nm.base, + "DROP FUNCTION IF EXISTS " + nm.fn + "()", + "DROP TABLE IF EXISTS " + nm.next, + "DROP TABLE IF EXISTS " + nm.shadow, + } + p, err := planTable(schema, table, cols, rules, key) + if err != nil { + return m, err + } + if p == nil { + m.Drop = append(m.Drop, "GRANT SELECT, INSERT, UPDATE, DELETE ON "+nm.base+" TO "+branch.MaskedRole) + return m, nil + } + if len(p.shadow) == 0 { + // Only partial and empty: no secret, so nothing is kept, and writes + // to the table cost nothing more. The view computes the masks. + m.View = viewSQL(nm, cols, p.own, nil, "", false) + return m, nil + } + m.Build = append(p.shadowDDL(nm.shadow), "GRANT SELECT ON "+nm.base+" TO "+branch.MaskerRole) + m.Build = append(m.Build, keepFn(nm.fn, []target{{nm.shadow, p}})...) + m.Build = append(m.Build, + fmt.Sprintf("CREATE TRIGGER bb_mask_i AFTER INSERT ON %s REFERENCING NEW TABLE AS n FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), + fmt.Sprintf("CREATE TRIGGER bb_mask_u AFTER UPDATE ON %s REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), + fmt.Sprintf("CREATE TRIGGER bb_mask_d AFTER DELETE ON %s REFERENCING OLD TABLE AS o FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), + fmt.Sprintf("CREATE TRIGGER bb_mask_t AFTER TRUNCATE ON %s FOR EACH STATEMENT EXECUTE FUNCTION %s()", nm.base, nm.fn), + ) + m.Fill = p.fill(nm.shadow) + m.View = viewSQL(nm, cols, p.own, p.shadow, p.k(), false) return m, nil } +// tableRekey is moving a table's masks from one key to another while masked +// readers keep reading the old ones (design, section 8): a second shadow, +// kept by the triggers under the new key beside the old, filled, then swapped +// in for the old in one transaction. +type tableRekey struct { + Start []string // the next shadow, and the triggers keeping both + Fill string // the next shadow's masks of the rows already there + Swap []string // the next shadow becomes the shadow, the view reads it, the triggers keep only it +} + +// rekeyTable makes it. A table with no keyed masks has nothing to move: ok is +// false. +func rekeyTable(schema, table string, cols []branch.MaskColumn, rules []branch.MaskRule, from, to string) (tableRekey, bool, error) { + var rk tableRekey + old, err := planTable(schema, table, cols, rules, from) + if err != nil || old == nil || len(old.shadow) == 0 { + return rk, false, err + } + nw, err := planTable(schema, table, cols, rules, to) + if err != nil { + return rk, false, err + } + nm := old.nm + rk.Start = append(writesWait(nm), "DROP TABLE IF EXISTS "+nm.next) + rk.Start = append(rk.Start, nw.shadowDDL(nm.next)...) + rk.Start = append(rk.Start, keepFn(nm.fn, []target{{nm.shadow, old}, {nm.next, nw}})...) + rk.Fill = nw.fill(nm.next) + rk.Swap = append(writesWait(nm), + "ALTER TABLE "+nm.shadow+" RENAME TO "+ident(nm.shadowName+"__old"), + "ALTER TABLE "+nm.next+" RENAME TO "+ident(nm.shadowName), + ) + // The view now reads the new shadow (by its name, now the shadow's), and + // keeps its grants. + rk.Swap = append(rk.Swap, viewSQL(nm, cols, nw.own, nw.shadow, nw.k(), true)[0]) + rk.Swap = append(rk.Swap, keepFn(nm.fn, []target{{nm.shadow, nw}})...) + rk.Swap = append(rk.Swap, "DROP TABLE "+branch.MaskSchema+"."+ident(nm.shadowName+"__old")) + return rk, true, nil +} + +// repointTable has a table's triggers write its masks under another name for +// the same key: once a rotation's new key is in the domain's own file. +func repointTable(schema, table string, cols []branch.MaskColumn, rules []branch.MaskRule, key string) ([]string, error) { + p, err := planTable(schema, table, cols, rules, key) + if err != nil || p == nil || len(p.shadow) == 0 { + return nil, err + } + return append(writesWait(p.nm), keepFn(p.nm.fn, []target{{p.nm.shadow, p}})...), nil +} + +// writesWait starts a change to what a table's triggers write: it waits for +// the writes already running, which finish with the triggers as they were, +// and holds new ones until it commits, so no write runs triggers changed under +// it (one would write to a shadow renamed or dropped, or under a key gone). +// Reads go on. Waiting is bounded: a long transaction on the table is not +// waited behind with every new write queued after it; the change is tried +// again. +func writesWait(nm names) []string { + return []string{ + "SET LOCAL lock_timeout = '" + lockWait + "'", + "LOCK TABLE " + nm.base + " IN SHARE ROW EXCLUSIVE MODE", + } +} + +// lockWait bounds writesWait. +const lockWait = "3s" + // viewSQL is the view: every column, in order, the masked ones as their -// masks, joined to the shadow on key k when there is one. -func viewSQL(nm names, cols []branch.MaskColumn, own map[string]branch.MaskRule, shadow []shadowCol, k string) []string { +// masks, joined to the shadow on key k when there is one. With replace, the +// view there is replaced and keeps its owner and grants. +func viewSQL(nm names, cols []branch.MaskColumn, own map[string]branch.MaskRule, shadow []shadowCol, k string, replace bool) []string { kept := map[string]bool{} for _, s := range shadow { kept[s.name] = true @@ -283,13 +410,21 @@ func viewSQL(nm names, cols []branch.MaskColumn, own map[string]branch.MaskRule, from += fmt.Sprintf(" JOIN %s m ON m.%s = n.%s", nm.shadow, k, k) } return []string{ - fmt.Sprintf("CREATE VIEW %s AS SELECT %s FROM %s", nm.view, strings.Join(sel, ", "), from), + fmt.Sprintf("CREATE %sVIEW %s AS SELECT %s FROM %s", map[bool]string{true: "OR REPLACE "}[replace], nm.view, strings.Join(sel, ", "), from), "ALTER VIEW " + nm.view + " OWNER TO " + branch.MaskerRole, "GRANT SELECT ON " + nm.base + " TO " + branch.MaskerRole, "GRANT SELECT ON " + nm.view + " TO " + branch.MaskedRole, } } +// maskTablesDDL keeps each masked table's key (step 2): its domain, and the +// key of that domain its masks were made under. A table not in it is the +// default domain's, under its first key, as on a branch set up in step 1. +const maskTablesDDL = `CREATE TABLE IF NOT EXISTS bb.mask_tables ( + schema_name text NOT NULL, table_name text NOT NULL, + key_domain text NOT NULL DEFAULT 'default', key_gen int NOT NULL DEFAULT 1, + PRIMARY KEY (schema_name, table_name))` + // setupSQL is what a branch needs before any table is masked: the hash // (bb_identity 0.2), the roles, the schema, the rules table, the fake values, // and a masked reader's rights on every table that is not masked. @@ -315,6 +450,8 @@ END $bbsetup$`, set_by text NOT NULL, set_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (schema_name, table_name, column_name, json_path))`, "REVOKE ALL ON bb.mask_rules FROM PUBLIC", + maskTablesDDL, + "REVOKE ALL ON bb.mask_tables FROM PUBLIC", // fake: a realistic value, the same for the same input, chosen by the // hash. Never granted: it takes a hash, and is called by the triggers. `CREATE OR REPLACE FUNCTION ` + branch.MaskSchema + `.fake(kind text, h text) RETURNS text LANGUAGE sql IMMUTABLE AS $bbfake$ diff --git a/enterprise/masking/sql_test.go b/enterprise/masking/sql_test.go index 1786569..cade900 100644 --- a/enterprise/masking/sql_test.go +++ b/enterprise/masking/sql_test.go @@ -41,7 +41,7 @@ var everyRule = []branch.MaskRule{ // the hash never in the view, a trigger a statement, and the rights taken // before anything is made. func TestMaskTableEveryRule(t *testing.T) { - m, err := maskTable("public", "customers", customers, everyRule) + m, err := maskTable("public", "customers", customers, everyRule, "default") if err != nil { t.Fatal(err) } @@ -87,7 +87,7 @@ func TestMaskTableEveryRule(t *testing.T) { // Only partial and empty need no key: no shadow, no trigger, nothing more on // a write. func TestMaskTableWithoutAShadow(t *testing.T) { - m, err := maskTable("public", "customers", customers, []branch.MaskRule{rule("aadhaar", "", "partial", "aadhaar")}) + m, err := maskTable("public", "customers", customers, []branch.MaskRule{rule("aadhaar", "", "partial", "aadhaar")}, "default") if err != nil { t.Fatal(err) } @@ -101,7 +101,7 @@ func TestMaskTableWithoutAShadow(t *testing.T) { // No rules: the masks go, and the masked reader's rights come back. func TestMaskTableNoRules(t *testing.T) { - m, err := maskTable("public", "customers", customers, nil) + m, err := maskTable("public", "customers", customers, nil, "default") if err != nil { t.Fatal(err) } @@ -127,7 +127,7 @@ func TestMaskTableRefuses(t *testing.T) { "array path": {customers, []branch.MaskRule{rule("prefs", "$.orders[*].email", "hash", "email")}, "array's elements"}, "both levels": {customers, []branch.MaskRule{rule("prefs", "", "empty", "other"), rule("prefs", "$.a", "partial", "other")}, "take one off"}, } { - _, err := maskTable("public", "customers", c.cols, c.rules) + _, err := maskTable("public", "customers", c.cols, c.rules, "default") if err == nil || !errors.Is(err, branch.ErrInvalidRequest) || !strings.Contains(err.Error(), c.want) { t.Errorf("%s: %v", name, err) } @@ -144,3 +144,93 @@ func TestMaskNames(t *testing.T) { t.Fatal(namesFor("public", "orders")) } } + +// A table's masks under a named domain hash under that domain's key. +func TestMaskTableUnderADomain(t *testing.T) { + m, err := maskTable("public", "customers", customers, everyRule, "tenant_a") + if err != nil { + t.Fatal(err) + } + build := strings.Join(m.Build, ";\n") + if !strings.Contains(build, `bbid_mask_hmac('tenant_a', (n."email")::text)`) || strings.Contains(build, "'default'") { + t.Errorf("not under tenant_a's key:\n%s", build) + } +} + +// Rekeying: a second shadow kept under the new key beside the old, filled +// without replacing what the triggers wrote, then swapped in with the view +// replaced in place (its grants kept) and the triggers keeping only it. +func TestRekeyTable(t *testing.T) { + rk, keyed, err := rekeyTable("public", "customers", customers, everyRule, "default", "default--next") + if err != nil || !keyed { + t.Fatalf("keyed %v: %v", keyed, err) + } + start := strings.Join(rk.Start, ";\n") + for _, want := range []string{ + `DROP TABLE IF EXISTS bb_mask."public__customers__next"`, + `CREATE TABLE bb_mask."public__customers__next"`, + `INSERT INTO bb_mask."public__customers" ("id", "name", "email"`, + `bbid_mask_hmac('default', (n."email")::text)`, + `INSERT INTO bb_mask."public__customers__next" ("id", "name", "email"`, + `bbid_mask_hmac('default--next', (n."email")::text)`, + `TRUNCATE bb_mask."public__customers", bb_mask."public__customers__next"`, + `CREATE OR REPLACE FUNCTION bb_mask."keep__public__customers"()`, + } { + if !strings.Contains(start, want) { + t.Errorf("start is missing %q:\n%s", want, start) + } + } + // Writes wait for the triggers to change, and only those: reads go on. + for name, stmts := range map[string][]string{"start": rk.Start, "swap": rk.Swap} { + if stmts[0] != "SET LOCAL lock_timeout = '3s'" || stmts[1] != `LOCK TABLE "public"."customers" IN SHARE ROW EXCLUSIVE MODE` { + t.Errorf("%s does not hold writes first: %q", name, stmts[:2]) + } + } + if strings.Contains(start, "DROP VIEW") || strings.Contains(start, "REVOKE ALL ON \"public\"") { + t.Errorf("a masked reader loses the table while it is rekeyed:\n%s", start) + } + if !strings.Contains(rk.Fill, `bb_mask."public__customers__next"`) || !strings.Contains(rk.Fill, "DO NOTHING") || + !strings.Contains(rk.Fill, "'default--next'") || !strings.Contains(rk.Fill, "COMMIT") { + t.Errorf("the fill:\n%s", rk.Fill) + } + swap := strings.Join(rk.Swap, ";\n") + for _, want := range []string{ + `ALTER TABLE bb_mask."public__customers" RENAME TO "public__customers__old"`, + `ALTER TABLE bb_mask."public__customers__next" RENAME TO "public__customers"`, + `CREATE OR REPLACE VIEW bb_mask."customers"`, + `DROP TABLE bb_mask."public__customers__old"`, + } { + if !strings.Contains(swap, want) { + t.Errorf("the swap is missing %q:\n%s", want, swap) + } + } + if strings.Contains(swap, "'default'") || strings.Contains(swap, "__next\" (") || strings.Contains(swap, "GRANT") { + t.Errorf("after the swap the triggers keep only the new shadow, under the new key, and grants are kept:\n%s", swap) + } + if i, j := strings.Index(swap, "RENAME TO \"public__customers\""), strings.Index(swap, "CREATE OR REPLACE VIEW"); i < 0 || j < i { + t.Errorf("the view is replaced before the new shadow has the shadow's name:\n%s", swap) + } +} + +// Only partial and empty: no key, so nothing to rekey. +func TestRekeyTableNothingKeyed(t *testing.T) { + _, keyed, err := rekeyTable("public", "customers", customers, []branch.MaskRule{rule("aadhaar", "", "partial", "aadhaar")}, "default", "x") + if err != nil || keyed { + t.Fatalf("keyed %v: %v", keyed, err) + } +} + +// After the new key is in the domain's file, the triggers write under the +// domain's name again, and nothing else changes. +func TestRepointTable(t *testing.T) { + stmts, err := repointTable("public", "customers", customers, everyRule, "default") + if err != nil { + t.Fatal(err) + } + all := strings.Join(stmts, ";\n") + if !strings.HasPrefix(all, "SET LOCAL lock_timeout") || !strings.Contains(all, `LOCK TABLE "public"."customers" IN SHARE ROW EXCLUSIVE MODE`) || + !strings.Contains(all, `CREATE OR REPLACE FUNCTION bb_mask."keep__public__customers"()`) || + strings.Contains(all, "--next") || strings.Contains(all, "DROP") || !strings.Contains(all, "'default'") { + t.Errorf("repointing:\n%s", all) + } +} diff --git a/internal/auth/audit.go b/internal/auth/audit.go index ae55b66..52b99b9 100644 --- a/internal/auth/audit.go +++ b/internal/auth/audit.go @@ -133,6 +133,7 @@ const ( EvMaskSetup = "mask.setup" EvMaskRule = "mask.rule" EvMaskAccount = "mask.account" + EvMaskKey = "mask.key" // The databases' audit log (F6) turned on or off: who decided. Whoever // can stop the record of who did what is someone an auditor asks about. EvAuditLogOn = "audit-log.on" diff --git a/internal/branch/mask.go b/internal/branch/mask.go index f5c5d30..e44b21f 100644 --- a/internal/branch/mask.go +++ b/internal/branch/mask.go @@ -433,6 +433,10 @@ type Masker struct { SetRule func(branch string, r MaskRule) error DropRule func(branch, schema, table, column, path, actor string) error MaskTables func(branch string) ([]string, error) // the tables whose masks are ready + // Step 2 (maskkey.go): a domain's key rotated on every running branch, + // and a table's masks put under a domain. + Rotate func(domain, actor string, say func(string)) error + UseDomain func(branch, schema, table, domain, actor string) error } var masker *Masker diff --git a/internal/branch/maskkey.go b/internal/branch/maskkey.go new file mode 100644 index 0000000..99e1ef5 --- /dev/null +++ b/internal/branch/maskkey.go @@ -0,0 +1,507 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package branch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "slices" + "sort" + "strconv" + "strings" + "time" + + "github.com/thefoxbyte/foxbyte/internal/brand" +) + +// Masking keys (F5, Gate 1 step 2; design, section 8): the install's key +// domains, each one HMAC key, and rotating them. +// +// A key is a file the database server reads, /.key, and +// the engine never reads it back: what is kept here is about the keys, never +// a key. Every branch shares the install's keys, because the directory is +// mounted into every database. +// +// Rotation makes the new key beside the old one, as --next.key, has +// each table's masks written again under it while masked readers keep reading +// the old ones, swaps each table over when it is done, then moves the new key +// onto .key, which replaces the old key's file, so the old key is gone. +// A domain a person names is never a next key's name: it has no "-". + +// DefaultMaskDomain is the install's first domain, made by `fox mask setup`. +const DefaultMaskDomain = "default" + +// nextKeySuffix names a rotation's new key until it replaces the old one. +const nextKeySuffix = "--next" + +// MaskDomainRE is a domain a person may name: a file name, short enough that +// its next key's name is one too (bb_identity takes 1 to 32 characters). +var MaskDomainRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,23}$`) + +// NextMaskKey is the name a domain's new key has during a rotation. +func NextMaskKey(domain string) string { return domain + nextKeySuffix } + +// MaskKey is what is kept about one domain's key. +type MaskKey struct { + Domain string `json:"domain"` + Gen int `json:"gen"` // 1 for the key first made, one more at each rotation + Made string `json:"made"` + By string `json:"by"` + Rotated string `json:"rotated,omitempty"` + RotBy string `json:"rotatedBy,omitempty"` + // Pending: a rotation began and has not finished, so --next.key + // is its new key, which running it again carries on with. + Pending bool `json:"pending,omitempty"` + // UsedOn: the branches a table was put under the domain on, and has not + // been taken off since. Read when a branch is stopped and cannot be asked. + UsedOn []string `json:"usedOn,omitempty"` +} + +var maskKeysPath = func() string { return brand.StatePath("mask-keys.json") } + +// MaskKeys lists the install's domains. The default domain made before key +// records were kept (step 1) is its first key. Free: reading about the keys +// is reading. +func MaskKeys() []MaskKey { + var keys []MaskKey + if b, err := os.ReadFile(maskKeysPath()); err == nil { + _ = json.Unmarshal(b, &keys) + } + if MaskOn() && findMaskKey(keys, DefaultMaskDomain) < 0 { + // Made by setup, which keeps no record: when the key's file was written. + k := MaskKey{Domain: DefaultMaskDomain, Gen: 1} + if out, err := capture("stat", "-c", "%Y", maskKeyFile(DefaultMaskDomain)); err == nil { + if secs, err := strconv.ParseInt(strings.TrimSpace(out), 10, 64); err == nil && secs > 0 { + k.Made = time.Unix(secs, 0).UTC().Format(time.RFC3339) + } + } + keys = append(keys, k) + } + sort.Slice(keys, func(i, j int) bool { + if (keys[i].Domain == DefaultMaskDomain) != (keys[j].Domain == DefaultMaskDomain) { + return keys[i].Domain == DefaultMaskDomain + } + return keys[i].Domain < keys[j].Domain + }) + return keys +} + +func findMaskKey(keys []MaskKey, domain string) int { + for i, k := range keys { + if k.Domain == domain { + return i + } + } + return -1 +} + +// MaskKeyFor is one domain's record, and whether the install has it. +func MaskKeyFor(domain string) (MaskKey, bool) { + keys := MaskKeys() + if i := findMaskKey(keys, domain); i >= 0 { + return keys[i], true + } + return MaskKey{}, false +} + +// SaveMaskKeys writes the records. Enterprise calls it. +func SaveMaskKeys(keys []MaskKey) error { + b, err := json.MarshalIndent(keys, "", " ") + if err != nil { + return err + } + tmp := maskKeysPath() + ".tmp" + if err := os.WriteFile(tmp, b, 0o600); err != nil { + return err + } + return os.Rename(tmp, maskKeysPath()) +} + +// RecordMaskKey keeps, or replaces, one domain's record. +func RecordMaskKey(k MaskKey) error { + keys := MaskKeys() + if i := findMaskKey(keys, k.Domain); i >= 0 { + keys[i] = k + } else { + keys = append(keys, k) + } + return SaveMaskKeys(keys) +} + +// ForgetMaskKey drops a domain's record. +func ForgetMaskKey(domain string) error { + keys := MaskKeys() + if i := findMaskKey(keys, domain); i >= 0 { + keys = append(keys[:i], keys[i+1:]...) + } + return SaveMaskKeys(keys) +} + +// CheckMaskDomain refuses a name a person may not give a domain. +func CheckMaskDomain(d string) error { + if !MaskDomainRE.MatchString(d) { + return fmt.Errorf("%w: a key domain is a lowercase letter, then up to 23 of a-z, 0-9 and _, not %q", ErrInvalidRequest, d) + } + return nil +} + +func maskKeyFile(name string) string { return MaskKeyDir() + "/" + name + ".key" } + +// MaskKeyFileExists reports whether a key's file is there (the directory is +// the server's, so it is asked as root). +func MaskKeyFileExists(name string) bool { return quietOK("test", "-s", maskKeyFile(name)) } + +// MakeNextMaskKey makes a domain's rotation key. With resume, a rotation cut +// short carries on with the key it made, so the masks it already wrote stay +// right; otherwise a next key left from before (kept for a branch stopped +// during a rotation: it is the key now in use) is replaced by a new one. +func MakeNextMaskKey(domain string, resume bool) error { + if err := CheckMaskDomain(domain); err != nil { + return err + } + if !resume { + if err := RemoveMaskKey(NextMaskKey(domain)); err != nil { + return err + } + } + return EnsureMaskKey(NextMaskKey(domain)) +} + +// PromoteMaskKey puts a domain's rotation key in place of its key: a copy +// moved over the old file, so the old key is gone and, from then on, both +// names hold the new one until the next key is removed. +func PromoteMaskKey(domain string) error { + if err := CheckMaskDomain(domain); err != nil { + return err + } + next, cur := maskKeyFile(NextMaskKey(domain)), maskKeyFile(domain) + tmp := cur + ".new" + script := fmt.Sprintf("set -e; umask 077; cp %[1]s %[2]s; chown %[3]s:%[3]s %[2]s; chmod 0400 %[2]s; mv %[2]s %[4]s", + shellQuote(next), shellQuote(tmp), postgresUID, shellQuote(cur)) + return run("sh", "-c", script) +} + +// RemoveMaskKey removes a key's file: a rotation's next key once nothing +// reads it, or a domain's key once no table uses it. +func RemoveMaskKey(name string) error { + if !maskDomainOK(name) { + return fmt.Errorf("%w: %q is not a key's name", ErrInvalidRequest, name) + } + return run("rm", "-f", "--", maskKeyFile(name)) +} + +// StoppedBranches lists the branches that are not running: their masks cannot +// be read or written until they start. +func StoppedBranches() ([]string, error) { + usages, err := activeStorage().usage() + if err != nil { + return nil, err + } + var out []string + for _, u := range usages { + if strings.Contains(u.Name, "/") || u.Name == "restore" || u.Name == "standby" { + continue + } + if ContainerState(u.Name) != "running" { + out = append(out, u.Name) + } + } + sort.Strings(out) + return out, nil +} + +// mayUseDomain reports whether a stopped branch may have a table under a +// domain. A table is put under one on a running branch, which is recorded; so +// only a branch it was put on, or one made since the domain was (its data may +// be a copy of one it was put on), may. A branch whose making time cannot be +// read may. +func mayUseDomain(k MaskKey, name string, madeAt func(string) (time.Time, bool)) bool { + if slices.Contains(k.UsedOn, name) { + return true + } + made, err := time.Parse(time.RFC3339, k.Made) + t, ok := madeAt(name) + return err != nil || !ok || !t.Before(made) +} + +// branchMadeAt is when a branch's data was made: its dataset, or subvolume. +var branchMadeAt = func(name string) (time.Time, bool) { + var out string + var err error + if _, z := activeStorage().(zfsStorage); z { + out, err = capture("zfs", "get", "-Hp", "-o", "value", "creation", dataset(name)) + } else { + out, err = capture("stat", "-c", "%W", mountpoint(name)) + } + secs, perr := strconv.ParseInt(strings.TrimSpace(out), 10, 64) + if err != nil || perr != nil || secs <= 0 { + return time.Time{}, false + } + return time.Unix(secs, 0), true +} + +// MaskTableKey is a masked table's key on a branch. +type MaskTableKey struct { + Branch string `json:"branch"` + Schema string `json:"schema"` + Table string `json:"table"` + Domain string `json:"domain"` + Gen int `json:"gen"` // the key its masks were made under + Keyed bool `json:"keyed"` // whether it has hashed or faked masks, which the key makes +} + +// maskTablesSQL reads a branch's masked tables and their keys: every table +// with a rule, and every table a domain was chosen for. +const maskTablesSQL = `SELECT coalesce(json_agg(json_build_object('schema', s, 'table', t, 'domain', coalesce(d, 'default'), + 'gen', coalesce(g, 1), 'keyed', coalesce(k, false)) ORDER BY s, t), '[]'::json) + FROM (SELECT coalesce(r.s, m.schema_name) s, coalesce(r.t, m.table_name) t, m.key_domain d, m.key_gen g, r.k + FROM (SELECT schema_name s, table_name t, bool_or(rule IN ('hash', 'fake') OR json_path <> '') k + FROM bb.mask_rules GROUP BY 1, 2) r + FULL JOIN bb.mask_tables m ON m.schema_name = r.s AND m.table_name = r.t) x` + +// maskTablesNoKeysSQL is the same on a branch set up before key domains +// (step 1): every table is the default domain's, under its first key. +const maskTablesNoKeysSQL = `SELECT coalesce(json_agg(json_build_object('schema', schema_name, 'table', table_name, 'domain', 'default', + 'gen', 1, 'keyed', k) ORDER BY schema_name, table_name), '[]'::json) + FROM (SELECT schema_name, table_name, bool_or(rule IN ('hash', 'fake') OR json_path <> '') k FROM bb.mask_rules GROUP BY 1, 2) x` + +// MaskTableKeys reads a running branch's masked tables and their keys. Free. +func MaskTableKeys(name string) ([]MaskTableKey, error) { + if ok, err := RelationExists(name, "bb.mask_rules"); err != nil || !ok { + return []MaskTableKey{}, err + } + sql := maskTablesNoKeysSQL + if ok, err := RelationExists(name, "bb.mask_tables"); err != nil { + return nil, err + } else if ok { + sql = maskTablesSQL + } + out, err := QuerySQL(name, sql) + if err != nil { + return nil, fmt.Errorf("reading the masked tables on %s: %w (%s)", name, err, strings.TrimSpace(out)) + } + var ts []MaskTableKey + if err := json.Unmarshal([]byte(strings.TrimSpace(out)), &ts); err != nil { + return nil, err + } + for i := range ts { + ts[i].Branch = name + } + if ts == nil { + ts = []MaskTableKey{} + } + return ts, nil +} + +// MaskKeyUse is a domain with where it is used. +type MaskKeyUse struct { + MaskKey + Tables []MaskTableKey `json:"tables"` // on running branches + Stale []MaskTableKey `json:"stale"` // keyed masks made under an older key + Next bool `json:"next"` // a rotation was cut short: run it again to finish it +} + +// MaskKeysStatus is the install's keys, with the tables using each on the +// running branches, and the branches that could not be read. Free. +type MaskKeysStatus struct { + Keys []MaskKeyUse `json:"keys"` + Stopped []string `json:"stopped"` + Writes bool `json:"writes"` +} + +// MaskKeysState reads it. +func MaskKeysState() (MaskKeysStatus, error) { + st := MaskKeysStatus{Keys: []MaskKeyUse{}, Stopped: []string{}, Writes: requireMasker() == nil} + keys := MaskKeys() + if len(keys) == 0 { + return st, nil + } + byDomain := map[string]*MaskKeyUse{} + for _, k := range keys { + st.Keys = append(st.Keys, MaskKeyUse{MaskKey: k, Tables: []MaskTableKey{}, Stale: []MaskTableKey{}, Next: k.Pending}) + } + for i := range st.Keys { + byDomain[st.Keys[i].Domain] = &st.Keys[i] + } + names, err := RunningBranches() + if err != nil { + return st, err + } + for _, n := range names { + ts, err := MaskTableKeys(n) + if err != nil { + return st, err + } + for _, t := range ts { + u, ok := byDomain[t.Domain] + if !ok { + continue + } + u.Tables = append(u.Tables, t) + if t.Keyed && t.Gen < u.Gen { + u.Stale = append(u.Stale, t) + } + } + } + if s, err := StoppedBranches(); err == nil && s != nil { + st.Stopped = s + } + return st, nil +} + +// MaskRotateCost is what a rotation costs and changes, said before it runs. +func MaskRotateCost(domain string) string { + return fmt.Sprintf("What it does and costs:\n"+ + " - a new key for %[1]s is made, and every hashed or faked mask under the old\n"+ + " one is written again, a batch at a time: about 21 seconds a million rows,\n"+ + " as measured. Partial and empty masks use no key and are not touched.\n"+ + " - masked readers keep reading the old masks until each table is done, then\n"+ + " the new ones. A table's writes pause for a moment as it starts and as it\n"+ + " is swapped over, and cost about twice the usual masking in between.\n"+ + " - every hash and fake changes, so masks copied out before do not match\n"+ + " masks read after. That is the point of rotating.\n"+ + " - the old key is deleted once every table is done. There is no undo.\n", domain) +} + +// ---- the paid half ---- + +// ErrMaskDomainInUse refuses deleting a domain a table uses. +var ErrMaskDomainInUse = errors.New("a key domain is deleted only when no table uses it") + +// CreateMaskDomain makes a new key domain. Enterprise. +func CreateMaskDomain(domain, actor string) error { + if err := requireMasker(); err != nil { + return err + } + if err := CheckMaskDomain(domain); err != nil { + return err + } + if !MaskOn() { + return fmt.Errorf("%w: masking is not set up on this install: %s mask setup", ErrInvalidRequest, brand.CLI) + } + if _, ok := MaskKeyFor(domain); ok || MaskKeyFileExists(domain) { + return fmt.Errorf("%w: the install already has a key domain %q", ErrInvalidRequest, domain) + } + if err := EnsureMaskKey(domain); err != nil { + return fmt.Errorf("making %s's key: %w", domain, err) + } + return RecordMaskKey(MaskKey{Domain: domain, Gen: 1, Made: time.Now().UTC().Format(time.RFC3339), By: actor}) +} + +// DropMaskDomain deletes a key domain no table uses. A stopped branch cannot +// be asked, so one that may use the domain (mayUseDomain) refuses it: its +// tables could not be written once the key is gone. Enterprise. +func DropMaskDomain(domain, actor string) error { + if err := requireMasker(); err != nil { + return err + } + if domain == DefaultMaskDomain { + return fmt.Errorf("%w: the default domain is the install's own and stays", ErrInvalidRequest) + } + k, ok := MaskKeyFor(domain) + if !ok { + return fmt.Errorf("%w: the install has no key domain %q", ErrInvalidRequest, domain) + } + stopped, err := StoppedBranches() + if err != nil { + return err + } + var unread []string + for _, n := range stopped { + if mayUseDomain(k, n, branchMadeAt) { + unread = append(unread, n) + } + } + if len(unread) > 0 { + return fmt.Errorf("%w: %s is stopped and may use %s, so it cannot be asked: start or delete it first", + ErrMaskDomainInUse, strings.Join(unread, ", "), domain) + } + names, err := RunningBranches() + if err != nil { + return err + } + var using []string + for _, n := range names { + ts, err := MaskTableKeys(n) + if err != nil { + return err + } + for _, t := range ts { + if t.Domain == domain { + using = append(using, n+":"+t.Schema+"."+t.Table) + } + } + } + if len(using) > 0 { + return fmt.Errorf("%w: %s uses %s (%s mask key use default --table …)", ErrMaskDomainInUse, strings.Join(using, ", "), domain, brand.CLI) + } + for _, n := range []string{NextMaskKey(domain), domain} { + if err := RemoveMaskKey(n); err != nil { + return err + } + } + return ForgetMaskKey(domain) +} + +// RotateMaskKey rotates a domain's key on every running branch. say reports +// each table as it is done. Enterprise. +func RotateMaskKey(domain, actor string, say func(string)) error { + if err := requireMasker(); err != nil { + return err + } + if _, ok := MaskKeyFor(domain); !ok { + return fmt.Errorf("%w: the install has no key domain %q", ErrInvalidRequest, domain) + } + if masker.Rotate == nil { + return ErrMaskingNotLicensed + } + return masker.Rotate(domain, actor, say) +} + +// UseMaskDomain puts a table's masks on a branch under a domain's key: they +// are made again under it, and only that table's. Enterprise. +func UseMaskDomain(branchName, schema, table, domain, actor string) error { + if err := requireMasker(); err != nil { + return err + } + if _, ok := MaskKeyFor(domain); !ok { + return fmt.Errorf("%w: the install has no key domain %q: %s mask key create %s", ErrInvalidRequest, domain, brand.CLI, domain) + } + if masker.UseDomain == nil { + return ErrMaskingNotLicensed + } + if err := masker.UseDomain(branchName, schema, table, domain, actor); err != nil { + return err + } + return recordDomainUse(branchName) +} + +// recordDomainUse keeps, for each named domain, whether a table on a branch +// is under it, as read from the branch now. +func recordDomainUse(branchName string) error { + ts, err := MaskTableKeys(branchName) + if err != nil { + return err + } + using := map[string]bool{} + for _, t := range ts { + using[t.Domain] = true + } + keys := MaskKeys() + for i := range keys { + if keys[i].Domain == DefaultMaskDomain { + continue + } + on := slices.Contains(keys[i].UsedOn, branchName) + switch { + case using[keys[i].Domain] && !on: + keys[i].UsedOn = append(keys[i].UsedOn, branchName) + case !using[keys[i].Domain] && on: + keys[i].UsedOn = slices.DeleteFunc(keys[i].UsedOn, func(n string) bool { return n == branchName }) + } + } + return SaveMaskKeys(keys) +} diff --git a/internal/branch/maskkey_test.go b/internal/branch/maskkey_test.go new file mode 100644 index 0000000..feee4c0 --- /dev/null +++ b/internal/branch/maskkey_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package branch + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// A domain a person names is a file name, its next key's name is one too and +// never a name a person could give, and "-" is kept for that. +func TestCheckMaskDomain(t *testing.T) { + for _, ok := range []string{"default", "tenant_a", "t", "a23456789012345678901234"} { + if err := CheckMaskDomain(ok); err != nil { + t.Errorf("%q refused: %v", ok, err) + } + if !maskDomainOK(NextMaskKey(ok)) { + t.Errorf("%q's next key, %q, is not a name bb_identity reads", ok, NextMaskKey(ok)) + } + if CheckMaskDomain(NextMaskKey(ok)) == nil { + t.Errorf("%q's next key could be a domain", ok) + } + } + for _, bad := range []string{"", "Default", "1st", "a-b", "a.b", "../x", "a/b", "a b", "a234567890123456789012345", "x--next"} { + if err := CheckMaskDomain(bad); !errors.Is(err, ErrInvalidRequest) { + t.Errorf("%q: %v", bad, err) + } + } +} + +// The records: the default domain of an install set up before they were kept +// is its first key; a record is kept, replaced and dropped; the file is the +// owner's only. +func TestMaskKeyRecords(t *testing.T) { + dir := t.TempDir() + oldDir, oldPath := MaskKeyDir, maskKeysPath + t.Cleanup(func() { MaskKeyDir, maskKeysPath = oldDir, oldPath }) + MaskKeyDir = func() string { return filepath.Join(dir, "mask") } + maskKeysPath = func() string { return filepath.Join(dir, "mask-keys.json") } + + if len(MaskKeys()) != 0 { + t.Fatal("keys before masking was set up") + } + if err := os.Mkdir(MaskKeyDir(), 0o700); err != nil { + t.Fatal(err) + } + if k, ok := MaskKeyFor(DefaultMaskDomain); !ok || k.Gen != 1 || k.Made != "" { + t.Fatalf("a step 1 install's key: %+v %v", k, ok) + } + if err := RecordMaskKey(MaskKey{Domain: "tenant_b", Gen: 1, By: "cli:a"}); err != nil { + t.Fatal(err) + } + if err := RecordMaskKey(MaskKey{Domain: "tenant_a", Gen: 1}); err != nil { + t.Fatal(err) + } + if err := RecordMaskKey(MaskKey{Domain: "tenant_b", Gen: 2, Pending: true}); err != nil { + t.Fatal(err) + } + var names []string + for _, k := range MaskKeys() { + names = append(names, k.Domain) + } + if got := strings.Join(names, " "); got != "default tenant_a tenant_b" { + t.Errorf("the order: %s", got) + } + if k, _ := MaskKeyFor("tenant_b"); k.Gen != 2 || !k.Pending { + t.Errorf("not replaced: %+v", k) + } + if st, err := os.Stat(maskKeysPath()); err != nil || os.PathSeparator == '/' && st.Mode().Perm() != 0o600 { + t.Errorf("the file: %v %v", st, err) + } + if err := ForgetMaskKey("tenant_a"); err != nil { + t.Fatal(err) + } + if _, ok := MaskKeyFor("tenant_a"); ok { + t.Error("still there") + } + b, _ := os.ReadFile(maskKeysPath()) + if strings.Contains(string(b), "key\":") { + t.Errorf("a key in the records: %s", b) + } +} + +// What a rotation does is said before it runs. +func TestMaskRotateCost(t *testing.T) { + c := MaskRotateCost("tenant_a") + for _, want := range []string{"tenant_a", "21 seconds a million rows", "old masks until each table is done", + "do not match", "There is no undo", "Partial and empty"} { + if !strings.Contains(c, want) { + t.Errorf("missing %q:\n%s", want, c) + } + } +} + +// Without Enterprise every key change is refused as not licensed. +func TestMaskKeysNotLicensed(t *testing.T) { + old := masker + t.Cleanup(func() { masker = old }) + masker = nil + for name, err := range map[string]error{ + "create": CreateMaskDomain("tenant_a", "cli:me"), + "drop": DropMaskDomain("tenant_a", "cli:me"), + "rotate": RotateMaskKey("default", "cli:me", func(string) {}), + "use": UseMaskDomain("main", "public", "t", "tenant_a", "cli:me"), + } { + if !errors.Is(err, ErrNotLicensed) { + t.Errorf("%s: %v", name, err) + } + } +} + +// A stopped branch may use a domain if a table was put under it there, or if +// the branch was made since the domain (a copy of one that does); one made +// before and never used is not asked about. Unknown is "may". +func TestMayUseDomain(t *testing.T) { + made := time.Date(2026, 11, 6, 12, 0, 0, 0, time.UTC) + k := MaskKey{Domain: "tenant_a", Made: made.Format(time.RFC3339), UsedOn: []string{"used"}} + at := map[string]time.Time{"old": made.Add(-time.Hour), "used": made.Add(-time.Hour), "new": made.Add(time.Minute), "same": made} + madeAt := func(n string) (time.Time, bool) { t, ok := at[n]; return t, ok } + for n, want := range map[string]bool{"old": false, "used": true, "new": true, "same": true, "unknown": true} { + if got := mayUseDomain(k, n, madeAt); got != want { + t.Errorf("%s: %v, want %v", n, got, want) + } + } + if !mayUseDomain(MaskKey{Domain: "x"}, "old", madeAt) { + t.Error("a domain with no making time: every stopped branch may use it") + } +} diff --git a/internal/controlplane/masking.go b/internal/controlplane/masking.go index 9761884..9294e4a 100644 --- a/internal/controlplane/masking.go +++ b/internal/controlplane/masking.go @@ -23,8 +23,26 @@ import ( // POST /api/masking/rule {"branch", "schema", "table", "column", "path", "rule", "off", "apply"} // a rule's cost, or with apply sets it; off takes it off (Enterprise) // POST /api/masking/account {"email", "off"} masks an account, or unmasks it (Enterprise) +// GET /api/masking/keys the key domains and the tables using each: free +// POST /api/masking/key {"action": "create"|"rotate"|"drop"|"use", "domain", "branch", "schema", "table", "apply"} +// what a key change does, or with apply makes it (Enterprise) // // Each setup, rule and account change is in the security log. +// maskBody is what the masking calls take. +type maskBody struct { + Branch string `json:"branch"` + Schema string `json:"schema"` + Table string `json:"table"` + Column string `json:"column"` + Path string `json:"path"` + Rule string `json:"rule"` + Email string `json:"email"` + Off bool `json:"off"` + Apply bool `json:"apply"` + Action string `json:"action"` // the keys': create, rotate, drop, use + Domain string `json:"domain"` +} + func registerMasking(mux *http.ServeMux) { mux.HandleFunc("GET /api/masking", func(w http.ResponseWriter, r *http.Request) { if !isAdmin(r) { @@ -39,19 +57,8 @@ func registerMasking(mux *http.ServeMux) { writeJSON(w, 200, st) }) - type body struct { - Branch string `json:"branch"` - Schema string `json:"schema"` - Table string `json:"table"` - Column string `json:"column"` - Path string `json:"path"` - Rule string `json:"rule"` - Email string `json:"email"` - Off bool `json:"off"` - Apply bool `json:"apply"` - } - read := func(w http.ResponseWriter, r *http.Request) (body, string, bool) { - var b body + read := func(w http.ResponseWriter, r *http.Request) (maskBody, string, bool) { + var b maskBody if !requireFeature(w, edition.Masking) { return b, "", false } @@ -173,6 +180,100 @@ func registerMasking(mux *http.ServeMux) { record(r, actor, auth.EvMaskAccount, "", what+" "+email) writeJSON(w, 200, map[string]any{"email": email, "masked": !b.Off}) }) + + registerMaskKeys(mux, read, record) +} + +// registerMaskKeys is step 2's: the keys. Admins only, as the rest. +func registerMaskKeys(mux *http.ServeMux, read func(http.ResponseWriter, *http.Request) (maskBody, string, bool), + record func(*http.Request, string, string, string, string)) { + mux.HandleFunc("GET /api/masking/keys", func(w http.ResponseWriter, r *http.Request) { + if !isAdmin(r) { + writeErr(w, 403, errors.New("only an admin may read the masking keys")) + return + } + st, err := branch.MaskKeysState() + if err != nil { + maskFail(w, err) + return + } + writeJSON(w, 200, st) + }) + mux.HandleFunc("POST /api/masking/key", func(w http.ResponseWriter, r *http.Request) { + b, actor, ok := read(w, r) + if !ok { + return + } + if b.Schema == "" { + b.Schema = "public" + } + switch b.Action { + case "create": + if err := branch.CheckMaskDomain(b.Domain); err != nil { + maskFail(w, err) + return + } + if !b.Apply { + writeJSON(w, 200, map[string]any{"domain": b.Domain, "keyDir": branch.MaskKeyDir()}) + return + } + if err := branch.CreateMaskDomain(b.Domain, actor); err != nil { + maskFail(w, err) + return + } + record(r, actor, auth.EvMaskKey, "", "key domain "+b.Domain+" made") + writeJSON(w, 200, map[string]any{"made": b.Domain}) + case "rotate": + if _, ok := branch.MaskKeyFor(b.Domain); !ok { + maskFail(w, fmt.Errorf("%w: the install has no key domain %q", branch.ErrInvalidRequest, b.Domain)) + return + } + if !b.Apply { + writeJSON(w, 200, map[string]any{"domain": b.Domain, "cost": branch.MaskRotateCost(b.Domain)}) + return + } + var done []string + if err := branch.RotateMaskKey(b.Domain, actor, func(s string) { done = append(done, s) }); err != nil { + maskFail(w, err) + return + } + record(r, actor, auth.EvMaskKey, "", "key of domain "+b.Domain+" rotated") + writeJSON(w, 200, map[string]any{"rotated": b.Domain, "done": done}) + case "drop": + if !b.Apply { + writeJSON(w, 200, map[string]any{"domain": b.Domain}) + return + } + if err := branch.DropMaskDomain(b.Domain, actor); err != nil { + if errors.Is(err, branch.ErrMaskDomainInUse) { + writeErr(w, 409, err) + return + } + maskFail(w, err) + return + } + record(r, actor, auth.EvMaskKey, "", "key domain "+b.Domain+" deleted") + writeJSON(w, 200, map[string]any{"deleted": b.Domain}) + case "use": + if b.Table == "" { + writeErr(w, 400, errors.New("a domain is chosen for a table: table is needed")) + return + } + where := b.Schema + "." + b.Table + if !b.Apply { + writeJSON(w, 200, map[string]any{"domain": b.Domain, "branch": b.Branch, "table": where}) + return + } + if err := branch.UseMaskDomain(b.Branch, b.Schema, b.Table, b.Domain, actor); err != nil { + maskFail(w, err) + return + } + record(r, actor, auth.EvMaskKey, b.Branch, fmt.Sprintf("%s on %s put under key domain %s", where, b.Branch, b.Domain)) + writeJSON(w, 200, map[string]any{"table": where, "domain": b.Domain}) + default: + writeErr(w, 400, errors.New(`action is "create", "rotate", "drop" or "use"`)) + } + }) } func maskFail(w http.ResponseWriter, err error) { diff --git a/internal/controlplane/openapi.yaml b/internal/controlplane/openapi.yaml index 0c5615c..428c656 100644 --- a/internal/controlplane/openapi.yaml +++ b/internal/controlplane/openapi.yaml @@ -2297,6 +2297,68 @@ paths: "400": { description: No email, or an admin. } "403": { description: Not an admin, or not licensed for masking. } "401": { $ref: "#/components/responses/Unauthorized" } + /api/masking/keys: + get: + tags: [audit-log] + summary: The install's masking key domains, and the tables using each on the running branches (admins only). + description: >- + About the keys, never a key. Each domain's key number (one more at each rotation), when it was made and + rotated and by whom, whether a rotation was cut short, the tables under it on running branches, those whose + masks were made under an older key, and the branches not running, which could not be read. + responses: + "200": + description: Where the keys stand. + content: + application/json: + schema: + type: object + properties: + keys: + type: array + items: + type: object + properties: + domain: { type: string } + gen: { type: integer } + made: { type: string } + by: { type: string } + rotated: { type: string } + rotatedBy: { type: string } + next: { type: boolean, description: A rotation was cut short; rotating again finishes it. } + tables: { type: array, items: { type: object } } + stale: { type: array, items: { type: object } } + stopped: { type: array, items: { type: string } } + writes: { type: boolean } + "403": { description: Not an admin. } + "401": { $ref: "#/components/responses/Unauthorized" } + /api/masking/key: + post: + tags: [audit-log] + summary: Make, rotate or delete a key domain, or put a table under one, after a preview (admins only; Enterprise). + description: >- + create makes a domain with a key of its own; tables in different domains hash apart. rotate makes a new key + and writes every hashed or faked mask under the old one again, a batch at a time, masked readers reading the + old masks until each table is done; the old key is then deleted, with no undo. drop deletes a domain no + table uses, and is refused while a branch stopped since it was made, whose tables cannot be read. use puts a table's masks on a branch under a domain. + Without apply, what it does (with rotate, the cost); with apply, it is done. In the security log. + requestBody: + content: + application/json: + schema: + type: object + properties: + action: { type: string, enum: [create, rotate, drop, use] } + domain: { type: string, description: 'A lowercase letter, then up to 23 of a-z, 0-9 and _.' } + branch: { type: string, default: main } + schema: { type: string, default: public } + table: { type: string } + apply: { type: boolean } + responses: + "200": { description: 'A preview (rotate gives {"domain", "cost"}), or once done {"made"}, {"rotated", "done"}, {"deleted"} or {"table", "domain"}.', content: { application/json: { schema: { type: object } } } } + "400": { description: A domain name that is not allowed, a domain the install has not got, or no table. } + "403": { description: Not an admin, or not licensed for masking. } + "409": { description: The domain is used by a table, or a branch stopped since it was made. } + "401": { $ref: "#/components/responses/Unauthorized" } /api/audit-log/evidence: get: tags: [audit-log] diff --git a/scripts/integration_test.sh b/scripts/integration_test.sh index 1d61984..da5fc6c 100644 --- a/scripts/integration_test.sh +++ b/scripts/integration_test.sh @@ -87,7 +87,7 @@ rm -rf "${HOME:?}/${BRAND_STATE_DIR:?}/anchors/_audit-log" rm -f "$HOME/$BRAND_STATE_DIR/sync-commit" # Masking's key and masked accounts (F5): §11ai starts from an install that # has never set masking up. -sudo rm -rf "${HOME:?}/${BRAND_STATE_DIR:?}/mask"; rm -f "${HOME:?}/${BRAND_STATE_DIR:?}/masked-accounts.json" +sudo rm -rf "${HOME:?}/${BRAND_STATE_DIR:?}/mask"; rm -f "${HOME:?}/${BRAND_STATE_DIR:?}/masked-accounts.json" "${HOME:?}/${BRAND_STATE_DIR:?}/mask-keys.json" # And the primary pointer, for the same reason: a run cut short after a failover # leaves 'main' pointing at the promoted standby. `stop` above has already # removed every container, so there is no promoted standby left to protect, and @@ -3358,6 +3358,105 @@ assert_eq "with every rule taken off, no mask is left behind: no view, shadow, t pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS m_customers, m_orders, m_kyc, m_plain CASCADE" >/dev/null $S user delete mask-analyst@foxbyte.dev >/dev/null 2>&1 +echo "### 11aj. masking keys: domains and rotation (F5 Gate 1, step 2)" +# Enterprise: a key domain hashes apart from the others; a table's masks are +# put under one and back; a key is rotated while the application writes and a +# masked account reads, the old key deleted; a branch stopped meanwhile is +# named and its masks made again; a domain in use is not deleted. On main, +# set up by §11ai. Its tables and rules are taken off at the end. +$S branch delete keystale >/dev/null 2>&1 +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS k_people, k_orders CASCADE" >/dev/null +pg pg-main "CREATE TABLE k_people (id bigint PRIMARY KEY, email text, phone text); + INSERT INTO k_people SELECT g, 'person' || g || '@example.com', '98765' || lpad(g::text, 5, '0') FROM generate_series(1, 5000) g; + CREATE TABLE k_orders (id bigint PRIMARY KEY, cust_email text); + INSERT INTO k_orders VALUES (1, 'person1@example.com'), (2, 'person2@example.com')" >/dev/null +$S pii scan >/dev/null 2>&1 +KJ="$($S pii --json 2>/dev/null)" +kfid() { hjson "print([f['id'] for f in d['findings'] if f['table']=='$1' and f['column']=='$2'][0])" <<<"$KJ"; } +for at in "k_people email" "k_people phone" "k_orders cust_email"; do $S pii mark "$(kfid $at)" confirm >/dev/null 2>&1; done +for c in k_people.email k_people.phone k_orders.cust_email; do $S mask rule "$c" --yes >/dev/null 2>&1 || bad "setting the rule on $c"; done +printf 'password123\n' | $S user create key-analyst@foxbyte.dev >/dev/null 2>&1 +KK="$($S apikey create key-analyst@foxbyte.dev t 2>/dev/null | grep -o 'key_[A-Za-z0-9_-]*')" +$S mask account key-analyst@foxbyte.dev --yes >/dev/null 2>&1 || bad "masking the key analyst" +KD="${HOME:?}/${BRAND_STATE_DIR:?}/mask" +keyhex() { sudo od -An -tx1 "$KD/$1.key" | tr -d ' \n'; } +JOIN="SELECT count(*) FROM k_people p JOIN k_orders o ON o.cust_email = p.email" +assert_eq "the default domain: key 1, made when setup wrote it, the key itself never shown; masks join within it" \ + "$($S mask key --json | hjson "k=[x for x in d['keys'] if x['domain']=='default'][0]; print(k['gen'], bool(k['made']), 'key' in k)")|$($S mask key | grep -c "$(keyhex default)")|$(gwm "$KK" main "$JOIN")" "1 True False|0|2" + +assert_eq "a domain's name is checked: a capital, a dot or a dash is refused" \ + "$($S mask key create Tenant --yes 2>&1 | grep -c 'a key domain is a lowercase letter')|$($S mask key create a.b --yes 2>&1 | grep -c 'a key domain is')|$($S mask key create a-b --yes 2>&1 | grep -c 'a key domain is')" "1|1|1" +assert_eq "making a domain without --yes says what it is for, and makes nothing" \ + "$($S mask key create tenant_a 2>&1 | grep -c 'hash apart')|$(sudo test -e "$KD/tenant_a.key" && echo made || echo none)" "1|none" +$S mask key create tenant_a --yes >/dev/null 2>&1 || bad "making tenant_a" +assert_eq "with --yes: a key of its own, the server's alone, 32 bytes, unlike the default's" \ + "$(sudo stat -c '%u %a %s' "$KD/tenant_a.key")|$([ "$(keyhex tenant_a)" != "$(keyhex default)" ] && echo apart)|$($S mask key --json | hjson "print(sorted(k['domain'] for k in d['keys']))")" \ + "999 400 32|apart|['default', 'tenant_a']" +assert_eq "…a second domain of the same name is refused" "$($S mask key create tenant_a --yes 2>&1 | grep -c 'already has a key domain')" "1" +assert_eq "putting a table under a domain without --yes changes nothing" \ + "$($S mask key use tenant_a --table k_orders 2>&1 | grep -c 'Run again with --yes')|$(gwm "$KK" main "$JOIN")" "1|2" +$S mask key use tenant_a --table k_orders --yes >/dev/null 2>&1 || bad "putting k_orders under tenant_a" +assert_eq "under tenant_a, k_orders's masks are tenant_a's hashes and no longer join with the default domain's" \ + "$(gwm "$KK" main "$JOIN")|$(pg pg-main "SELECT count(*) FROM bb_mask.public__k_orders m JOIN k_orders o USING (id) WHERE m.cust_email = bb_f1.bbid_mask_hmac('tenant_a', o.cust_email)")|$($S mask key --json | hjson "print([t['table'] for k in d['keys'] if k['domain']=='tenant_a' for t in k['tables'] if t['branch']=='main'])")" \ + "0|2|['k_orders']" +DROPA="$($S mask key drop tenant_a --yes 2>&1)" +assert_eq "a domain a table uses is not deleted" \ + "$(grep -c 'main:public.k_orders uses tenant_a' <<<"$DROPA")|$(sudo test -e "$KD/tenant_a.key" && echo kept)" "1|kept" +grep -q 'main:public.k_orders uses tenant_a' <<<"$DROPA" || echo " it said: $DROPA" + +ROTD="$(mktemp -d)" +# A branch with masks, stopped through the rotation: named, then made again. +$S branch create keystale >/dev/null 2>&1 +$S branch suspend keystale >/dev/null 2>&1 +OLDKEY="$(keyhex default)" +OLDMASK="$(gwm "$KK" main "SELECT email FROM k_people WHERE id = 1")" +PRE="$($S mask key rotate default 2>&1)" +assert_eq "rotating without --yes says what it does and costs, names the tables and the stopped branch, and makes no key" \ + "$(grep -c 'There is no undo' <<<"$PRE")|$(grep -c '21 seconds a million rows' <<<"$PRE")|$(grep -c 'main: public.k_people' <<<"$PRE")|$(grep -c 'keystale' <<<"$PRE")|$(sudo test -e "$KD/default--next.key" && echo made || echo none)|$(keyhex default | grep -c "$OLDKEY")" \ + "1|1|1|1|none|1" +# The application writes and a masked account reads all through the rotation. +( for i in $(seq 1 60); do gwm "$KEY" main "UPDATE k_people SET email = 'w$i@example.com' WHERE id = $((i * 50)); INSERT INTO k_people VALUES ($((100000 + i)), 'n$i@example.com', '1')" | grep -E 'ERROR|FATAL|error'; done ) >"$ROTD/writer.log" 2>&1 & +RW=$! +( for _ in $(seq 1 40); do gwm "$KK" main "SELECT count(*) FROM k_people" | grep -vE '^[0-9]+$'; done ) >"$ROTD/reader.log" 2>&1 & +RR=$! +ROT="$($S mask key rotate default --yes 2>&1)"; rc=$? +wait "$RW" "$RR" +assert_eq "with --yes: every table in the domain is written again, and the old key's file now holds a new key" \ + "$rc|$(grep -c 'main: public.k_people written again under the new key' <<<"$ROT")|$(keyhex default | grep -c "$OLDKEY")|$(sudo stat -c '%u %a %s' "$KD/default.key")|$(sudo test -e "$KD/default--next.key" && echo left || echo gone)" \ + "0|1|0|999 400 32|gone" +[ "$rc" = 0 ] || { echo "--- what it said ---"; tail -8 <<<"$ROT"; echo "--- end ---"; } +assert_eq "…the writer was never refused, and the masked reader never failed, while it ran" \ + "$(grep -c . "$ROTD/writer.log")|$(grep -c . "$ROTD/reader.log")" "0|0" +cat "$ROTD/writer.log" "$ROTD/reader.log" | head -5 +assert_eq "…every mask, the writer's included, is under the new key, nothing is left over, and only tenant_a's table is untouched" \ + "$(pg pg-main "SELECT (SELECT count(*) FROM bb_mask.public__k_people m JOIN k_people p USING (id) WHERE m.email IS DISTINCT FROM bb_f1.bbid_mask_hmac('default', p.email)), (SELECT count(*) FROM k_people) = (SELECT count(*) FROM bb_mask.public__k_people), (SELECT count(*) FROM pg_class WHERE relname LIKE '%\_\_next' OR relname LIKE '%\_\_old'), (SELECT count(*) FROM bb_mask.public__k_orders m JOIN k_orders o USING (id) WHERE m.cust_email = bb_f1.bbid_mask_hmac('tenant_a', o.cust_email))")" \ + "0|t|0|2" +assert_eq "…the masked account reads the new masks, every hash changed: masks copied out before do not match" \ + "$(NEW="$(gwm "$KK" main "SELECT email FROM k_people WHERE id = 1")"; [ "$NEW" != "$OLDMASK" ] && [ ${#NEW} = 64 ] && echo changed)|$(gwm "$KK" main "SELECT count(*) FROM k_people WHERE email = '$OLDMASK'")" "changed|0" +assert_eq "…the default key is key 2, rotated by who asked; the new key is in no log" \ + "$($S mask key --json | hjson "k=[x for x in d['keys'] if x['domain']=='default'][0]; print(k['gen'], k['rotatedBy'].startswith('cli:'), k['next'])")|$(sudo docker logs pg-main 2>&1 | grep -c "$(keyhex default)")" \ + "2 True False|0" +$S branch resume keystale >/dev/null 2>&1 +assert_eq "the branch stopped through it is named as made under an older key (its tenant_a table is not), and set up again it is not" \ + "$($S mask key --json | hjson "print(sorted(t['branch']+':'+t['table'] for k in d['keys'] for t in k['stale']))")|$($S mask setup --branch keystale --yes >/dev/null 2>&1; $S mask key --json | hjson "print(sum(len(k['stale']) for k in d['keys']))")|$(pg pg-keystale "SELECT count(*) FROM bb_mask.public__k_people m JOIN k_people p USING (id) WHERE m.email IS DISTINCT FROM bb_f1.bbid_mask_hmac('default', p.email)")" \ + "['keystale:k_people']|0|0" +$S mask key use default --table k_orders --yes >/dev/null 2>&1 || bad "putting k_orders back under default" +assert_eq "back under the default domain, k_orders joins again, under the new key" "$(gwm "$KK" main "$JOIN")" "2" +assert_eq "the API: an admin reads the keys and a rotation's cost; a bad name is refused; the masked account is refused" \ + "$(curl -sk -H "$AUTH" https://localhost:8080/api/masking/keys | hjson "print(sorted(k['domain'] for k in d['keys']))")|$(curl -sk -H "$AUTH" -H 'Content-Type: application/json' -d '{"action":"rotate","domain":"tenant_a"}' https://localhost:8080/api/masking/key | hjson "print('There is no undo' in d['cost'])")|$(code "$KEY" -X POST -H 'Content-Type: application/json' -d '{"action":"create","domain":"Bad","apply":true}' https://localhost:8080/api/masking/key)|$(code "$KK" https://localhost:8080/api/masking/keys)|$(code "$KK" -X POST -H 'Content-Type: application/json' -d '{"action":"rotate","domain":"default","apply":true}' https://localhost:8080/api/masking/key)" \ + "['default', 'tenant_a']|True|400|403|403" +$S branch delete keystale >/dev/null 2>&1 +DROPB="$($S mask key drop tenant_a --yes 2>&1)" || bad "deleting tenant_a once no table uses it: $DROPB" +assert_eq "a domain no table uses is deleted, its key with it; the default cannot be" \ + "$(sudo test -e "$KD/tenant_a.key" && echo kept || echo gone)|$($S mask key --json | hjson "print([k['domain'] for k in d['keys']])")|$($S mask key drop default --yes 2>&1 | grep -c 'default domain is the install')" \ + "gone|['default']|1" +assert_eq "every key change is in the security log" "$($S audit log --limit 300 | grep -c 'mask.key')" "5" +for c in k_people.email k_people.phone k_orders.cust_email; do $S mask rule "$c" --off >/dev/null 2>&1; done +$S mask account key-analyst@foxbyte.dev --off --yes >/dev/null 2>&1 +pg pg-main "SET bb.allow_destructive = on; DROP TABLE IF EXISTS k_people, k_orders CASCADE" >/dev/null +$S user delete key-analyst@foxbyte.dev >/dev/null 2>&1 +rm -rf "${ROTD:?}" + echo "### 12. fox uninstall (B1)" # Removal used to be a list of commands to run by hand. This runs last: it takes # the stack apart, so nothing after it has a stack to use. diff --git a/scripts/integration_ui.sh b/scripts/integration_ui.sh index dbc5145..4314473 100644 --- a/scripts/integration_ui.sh +++ b/scripts/integration_ui.sh @@ -124,6 +124,9 @@ for id in $("$S" pii --branch e2emask --json 2>/dev/null | python3 -c 'import sy "$S" pii mark "$id" confirm --branch e2emask >/dev/null 2>&1 done "$S" mask setup --branch e2emask --yes >/dev/null 2>&1 +# masking.spec.ts's key test makes e2e_tenant and deletes it; a run cut short +# may have left it. +"$S" mask key drop e2e_tenant --yes >/dev/null 2>&1 printf '%s\n' "$PASSWORD" | "$S" user create e2e-masked@foxbyte.dev >/dev/null 2>&1 "$S" mask account e2e-masked@foxbyte.dev --off --yes >/dev/null 2>&1 @@ -241,6 +244,7 @@ echo "### cleanup" "$S" app remove uishop >/dev/null 2>&1 "$S" mask account e2e-masked@foxbyte.dev --off --yes >/dev/null 2>&1 "$S" user delete e2e-masked@foxbyte.dev >/dev/null 2>&1 +"$S" mask key drop e2e_tenant --yes >/dev/null 2>&1 for b in "$BRANCH" "$NEW_BRANCH" e2eui3 e2emask; do "$S" branch delete "$b" >/dev/null 2>&1; done cd "$ROOT" || exit 1 # A failure leaves the screenshots and traces where CI can collect them. diff --git a/web/src/api.ts b/web/src/api.ts index d16c4df..9764684 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -958,6 +958,17 @@ export const dropMaskRule = (branch: string, at: MaskPlace) => req('POST', `${API}/api/masking/rule`, { branch, ...at, off: true }) as Promise<{ off: string }> export const maskAccount = (email: string, off: boolean) => req('POST', `${API}/api/masking/account`, { email, off }) as Promise<{ email: string; masked: boolean }> +// Masking keys (F5 step 2): about the keys, never a key. +export type MaskTableKey = { branch: string; schema: string; table: string; domain: string; gen: number; keyed: boolean } +export type MaskKeyUse = { + domain: string; gen: number; made: string; by: string; rotated?: string; rotatedBy?: string + next: boolean; tables: MaskTableKey[]; stale: MaskTableKey[] +} +export type MaskKeysStatus = { keys: MaskKeyUse[]; stopped: string[]; writes: boolean } +export type MaskKeyAction = 'create' | 'rotate' | 'drop' | 'use' +export const getMaskKeys = () => req('GET', `${API}/api/masking/keys`) as Promise +export const maskKey = (action: MaskKeyAction, domain: string, apply: boolean, at?: { branch: string; schema: string; table: string }) => + req('POST', `${API}/api/masking/key`, { action, domain, apply, ...(at ?? {}) }) as Promise<{ cost?: string; done?: string[] }> const hardeningURL = (branch: string) => `${API}/api/hardening?branch=${encodeURIComponent(branch)}` export const getHardening = (branch: string) => req('GET', hardeningURL(branch)) as Promise diff --git a/web/src/pages/Masking.tsx b/web/src/pages/Masking.tsx index 3fd0fd2..aa50b2a 100644 --- a/web/src/pages/Masking.tsx +++ b/web/src/pages/Masking.tsx @@ -1,8 +1,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import { useCallback, useEffect, useState } from 'react' import { - applyMaskSetup, dropMaskRule, getMasking, getPersonalData, maskAccount, planMaskRule, planMaskSetup, setMaskRule, - type MaskPlace, type MaskRule, type MaskRuleName, type MaskStatus, type PIIFinding, + applyMaskSetup, dropMaskRule, getMaskKeys, getMasking, getPersonalData, maskAccount, maskKey, planMaskRule, planMaskSetup, + setMaskRule, type MaskKeysStatus, type MaskPlace, type MaskRule, type MaskRuleName, type MaskStatus, type PIIFinding, } from '../api' import { useConfirm } from '../confirm' import { useFeatures, why } from '../features' @@ -35,6 +35,7 @@ const allowed = (category: string): MaskRuleName[] => const where = (p: MaskPlace) => `${p.schema}.${p.table}.${p.column}${p.path ? ' ' + p.path : ''}` const key = (p: MaskPlace) => `${p.schema}\u0000${p.table}\u0000${p.column}\u0000${p.path ?? ''}` +const WARN = 'color-mix(in srgb, var(--red) 78%, var(--text))' const viewOf = (r: MaskRule) => (r.schema === 'public' ? r.table : `${r.schema}__${r.table}`) export default function Masking() { @@ -44,6 +45,9 @@ export default function Masking() { const [confirmed, setConfirmed] = useState([]) const [choice, setChoice] = useState>({}) const [email, setEmail] = useState('') + const [keys, setKeys] = useState(null) + const [domain, setDomain] = useState('') + const [move, setMove] = useState({ table: '', domain: '' }) const [busy, setBusy] = useState(false) const [err, setErr] = useState('') const [msg, setMsg] = useState('') @@ -56,6 +60,7 @@ export default function Masking() { try { const s = await getMasking(b) setSt(s) + try { setKeys(await getMaskKeys()) } catch { setKeys(null) } try { const pd = await getPersonalData(b) setConfirmed((pd.findings ?? []).filter(f => f.state === 'confirmed')) @@ -127,7 +132,67 @@ export default function Masking() { return off ? `${e} is unmasked.` : `${e} is masked.` }) + const createDomain = (d: string) => run(async () => { + const ok = await confirm({ + title: `Make key domain ${d}`, + message: <> +

    A key of its own: 32 random bytes beside the default key, never read back and never shown.

    +

    Tables put under it hash apart from every other domain’s, so their masks cannot be joined with them: for data + that must never be matched up, such as two tenants’. Joins work within one domain.

    + , + confirmText: 'Make it', + }) + if (!ok) return '' + await maskKey('create', d, true) + setDomain('') + return `Key domain ${d} is made.` + }) + + const rotate = (d: string) => run(async () => { + const p = await maskKey('rotate', d, false) + const u = keys?.keys.find(k => k.domain === d) + const keyed = (u?.tables ?? []).filter(t => t.keyed) + const ok = await confirm({ + title: `Rotate ${d}'s key`, + message: <> +
    {p.cost}
    +

    Written again: {keyed.length ? keyed.map(t => `${t.branch}: ${t.schema}.${t.table}`).join(', ') : 'no table on a running branch'}.

    + {(keys?.stopped.length ?? 0) > 0 &&

    Not running, so not rotated: {keys!.stopped.join(', ')}. Set masking up on each again once it starts.

    } + , + confirmText: 'Rotate the key', + }) + if (!ok) return '' + setBusy(true) + try { await maskKey('rotate', d, true) } finally { setBusy(false) } + return `${d}'s key is rotated, and the old one deleted.` + }) + + const dropDomain = (d: string) => run(async () => { + const ok = await confirm({ + title: `Delete key domain ${d}`, + message: 'Its key is deleted. Refused while a table on any branch uses it, or while a branch stopped since it was made, whose tables cannot be read.', + confirmText: 'Delete it', + }) + if (!ok) return '' + await maskKey('drop', d, true) + return `Key domain ${d} is deleted.` + }) + + const moveTable = (t: string, d: string) => run(async () => { + const [schema, table] = t.split('.') + const ok = await confirm({ + title: `Put ${t} under ${d}`, + message: `Only ${t}'s hashed and faked masks on ${branch} are written again, under ${d}'s key; masked accounts read the old ones until it is done. Its masks then join only with tables in ${d}.`, + confirmText: 'Move it', + }) + if (!ok) return '' + await maskKey('use', d, true, { branch, schema, table }) + return `${t} on ${branch} is under key domain ${d}.` + }) + const ruled = new Set((st?.rules ?? []).map(r => key(r))) + const ruleTables = [...new Set((st?.rules ?? []).map(r => `${r.schema}.${r.table}`))] + const domainOf = (t: string) => keys?.keys.find(k => k.tables.some(x => x.branch === branch && `${x.schema}.${x.table}` === t))?.domain ?? 'default' const ready = new Set(st?.ready ?? []) const open = confirmed.filter(f => !ruled.has(key({ schema: f.schema, table: f.table, column: f.column, path: f.path || undefined }))) @@ -148,7 +213,7 @@ export default function Masking() { - {err &&
    {err}
    } + {err &&
    {err}
    } {msg &&

    {msg}

    } {st && ( @@ -177,7 +242,7 @@ export default function Masking() {
    - + @@ -238,7 +303,7 @@ export default function Masking() {
    {where(r)} {r.rule} {r.category}{ready.has(viewOf(r)) ? 'made' : not made: masked accounts are refused the table}{ready.has(viewOf(r)) ? 'made' : not made: masked accounts are refused the table} {r.by}, {new Date(r.at).toLocaleString()} {setting && }
    {setting && st.on && ( -
    { e.preventDefault(); if (email.trim()) account(email.trim().toLowerCase(), false) }}>