From cbc301a2c9f12759fb4e48c2d224015a3a217228 Mon Sep 17 00:00:00 2001 From: SauravYadav12 Date: Fri, 6 Nov 2026 12:25:13 +0530 Subject: [PATCH 1/3] feat(f5): masking, Gate 1 step 2: key domains and rotation fox mask key [status|create|rotate|drop|use]: domains whose masks hash apart, a table's masks put under one, and a key rotated on every running branch while masked readers read the old masks until each table is done, then the old key deleted. A Keys panel on the Masking page, /api/masking/keys and /api/masking/key, every change in the security log. --- cmd/fox/main.go | 4 + cmd/fox/maskcmd.go | 170 +++++++++++ enterprise/masking/keys.go | 195 +++++++++++++ enterprise/masking/masking.go | 53 +++- enterprise/masking/sql.go | 317 +++++++++++++------- enterprise/masking/sql_test.go | 91 +++++- internal/auth/audit.go | 1 + internal/branch/mask.go | 4 + internal/branch/maskkey.go | 452 +++++++++++++++++++++++++++++ internal/branch/maskkey_test.go | 113 ++++++++ internal/controlplane/masking.go | 127 +++++++- internal/controlplane/openapi.yaml | 62 ++++ scripts/integration_test.sh | 98 ++++++- scripts/integration_ui.sh | 4 + web/src/api.ts | 11 + web/src/pages/Masking.tsx | 145 ++++++++- web/tests/masking.spec.ts | 55 ++++ 17 files changed, 1776 insertions(+), 126 deletions(-) create mode 100644 enterprise/masking/keys.go create mode 100644 internal/branch/maskkey.go create mode 100644 internal/branch/maskkey_test.go 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/enterprise/masking/keys.go b/enterprise/masking/keys.go new file mode 100644 index 0000000..e0bb42f --- /dev/null +++ b/enterprise/masking/keys.go @@ -0,0 +1,195 @@ +//go:build enterprise + +// SPDX-License-Identifier: LicenseRef-FoxByte-Enterprise-1.0 + +package masking + +import ( + "fmt" + "slices" + "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 := branch.RunMaskSQL(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 := branch.RunMaskSQL(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 +} + +// 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 := branch.RunMaskSQL(b, actor, stmts, false); err != nil { + return err + } + } + } + return nil +} diff --git a/enterprise/masking/masking.go b/enterprise/masking/masking.go index 9231ccb..867681e 100644 --- a/enterprise/masking/masking.go +++ b/enterprise/masking/masking.go @@ -8,22 +8,31 @@ import ( "encoding/json" "fmt" "strings" + "time" "github.com/thefoxbyte/foxbyte/internal/branch" "github.com/thefoxbyte/foxbyte/internal/brand" ) 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 { + made := !branch.MaskKeyFileExists(branch.DefaultMaskDomain) + if err := branch.EnsureMaskKey(branch.DefaultMaskDomain); err != nil { return fmt.Errorf("making the masking key: %w", err) } + if made { + if err := branch.RecordMaskKey(branch.MaskKey{Domain: branch.DefaultMaskDomain, Gen: 1, + Made: time.Now().UTC().Format(time.RFC3339), By: actor}); err != nil { + return err + } + } if err := branch.RunMaskSQL(b, actor, setupSQL(), false); err != nil { return fmt.Errorf("setting masking up on %s: %w", b, err) } @@ -99,7 +108,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 +144,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 +192,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..451213f 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 __. 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,104 @@ 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([]string{"DROP TABLE IF EXISTS " + nm.next}, 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 = []string{ + "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 keepFn(p.nm.fn, []target{{p.nm.shadow, p}}), nil +} + // 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 +392,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 +432,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..432b6d3 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,86 @@ 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) + } + } + 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, `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..a1f873c --- /dev/null +++ b/internal/branch/maskkey.go @@ -0,0 +1,452 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package branch + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "regexp" + "sort" + "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"` +} + +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 { + keys = append(keys, MaskKey{Domain: DefaultMaskDomain, Gen: 1}) + } + 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 +} + +// stoppedAt is when a branch's database last stopped, if it has one. +var stoppedAt = func(name string) (time.Time, bool) { + out, err := capture("docker", "inspect", "-f", "{{.State.FinishedAt}}", container(name)) + if err != nil { + return time.Time{}, false + } + t, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(out)) + if err != nil || t.Year() < 2000 { // never started: 0001-01-01 + return time.Time{}, false + } + return t, 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. Writers are not held up; a table's writes cost about twice\n"+ + " the usual masking while its masks are written again.\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 branch stopped since +// the domain was made is not asked, so it is refused: its tables might use +// the domain, and 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 + } + // A table is put under a domain on a running branch, so a branch that + // stopped before the domain was made cannot use it. Any other stopped + // branch might (a branch made from one that does has its tables). + made, _ := time.Parse(time.RFC3339, k.Made) + var unread []string + for _, n := range stopped { + if t, ok := stoppedAt(n); !ok || made.IsZero() || !t.Before(made) { + unread = append(unread, n) + } + } + if len(unread) > 0 { + return fmt.Errorf("%w: %s stopped after %s was made, so whether a table there uses it cannot be read: 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 + } + return masker.UseDomain(branchName, schema, table, domain, actor) +} diff --git a/internal/branch/maskkey_test.go b/internal/branch/maskkey_test.go new file mode 100644 index 0000000..9f91528 --- /dev/null +++ b/internal/branch/maskkey_test.go @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package branch + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +// 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) + } + } +} 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..eb8a419 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,102 @@ 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 by setup, 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']" +assert_eq "a domain a table uses is not deleted" \ + "$($S mask key drop tenant_a --yes 2>&1 | grep -c 'main:public.k_orders uses tenant_a')|$(sudo test -e "$KD/tenant_a.key" && echo kept)" "1|kept" + +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 -c ERROR; done ) >"$ROTD/writer.log" 2>&1 & +RW=$! +( for _ in $(seq 1 40); do gwm "$KK" main "SELECT count(*) FROM k_people" | grep -cvE '^[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" \ + "$(awk '{s+=$1} END {print s+0}' "$ROTD/writer.log")|$(awk '{s+=$1} END {print s+0}' "$ROTD/reader.log")" "0|0" +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 +$S mask key drop tenant_a --yes >/dev/null 2>&1 || bad "deleting tenant_a once no table uses it" +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) }}>