Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/fox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <domain> | rotate <domain> | drop <domain> [--yes]
The masking keys: domains that never hash alike, and rotating a key (Enterprise)
mask key use <domain> --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)
Expand Down
170 changes: 170 additions & 0 deletions cmd/fox/maskcmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email> [--off] [--yes] (Enterprise)
// fox mask key [status] [--json]
// fox mask key create|rotate|drop <domain> [--yes] (Enterprise)
// fox mask key use <domain> --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.
Expand All @@ -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:]
}
Expand Down Expand Up @@ -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 <domain> [--yes]\n"+
" %[1]s mask key use <domain> --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 <name>` 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, ", "))
}
}
4 changes: 2 additions & 2 deletions docs/FOX_Checklist.html
Original file line number Diff line number Diff line change
Expand Up @@ -771,11 +771,11 @@ <h2><span class="al">O</span>Personal data &amp; compliance<span class="cnt"></s
<tr data-id="O3" data-st="part"><td class="c"><i class="ck part"></i></td><td class="id">O3</td><td><b>Masking personal data</b> (point 4.6 and F5, Enterprise; <code>docs/FOX_F5_Masking_Design.md</code>)<ul class="subs">
<li class="ok">Gate 0 prototype (<code>make integration-f5</code>, 57/57 on PostgreSQL 18 and 16): the keyed hash in <code>bb_identity</code> 0.2, its key a file the server reads; a masked branch on a ZFS clone left with no real value in its files; the costs measured</li>
<li class="ok">Step 1: <code>fox mask setup|rule|account</code>, four API routes and the console&rsquo;s Masking page; rules (partial, keyed hash, fake, empty) only on confirmed findings, Aadhaar never hashed or faked; masks kept in shadow tables by statement triggers; masked accounts read masks and ordinary tables, never the real marked table, the hash or the key, and cannot write to marked tables; admins and a branch&rsquo;s owner see real values; the cost said before a rule is set</li>
<li class="todo">Step 2: key domains and rotation</li>
<li class="ok">Step 2: <code>fox mask key [status|create|rotate|drop|use]</code>, two API routes and a Keys panel on the Masking page; key domains whose masks hash apart; a table&rsquo;s masks put under one; a key rotated on every running branch while masked readers read the old masks until each table is done and writes pause only for a moment, then the old key deleted; a rotation cut short carries on when run again; a branch stopped through a rotation named, and its masks made again by setup; a domain in use, or perhaps in use on a branch stopped since it was made, is not deleted</li>
<li class="todo">Step 3: unmask, the audited lookup, unmask rights in the access review, reads audited</li>
<li class="todo">Step 4: every access path tested (COPY, functions, EXPLAIN, statistics, logs)</li>
<li class="todo">Step 5: masked branches, closed until masked and verified</li>
<li class="lim">One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights</li></ul></td><td class="tb">enterprise/masking unit; internal/branch mask_test.go; VM &sect;11ai; masking.spec.ts</td></tr>
<li class="lim">One-column primary keys only; a JSON path into an array, or a quoted key, is refused for now; masking stays on once set up; a masked account has no DDL rights; a domain&rsquo;s key is the install&rsquo;s, shared by every branch, so a branch stopped through a rotation keeps its old masks until set up again</li></ul></td><td class="tb">enterprise/masking unit; internal/branch mask_test.go, maskkey_test.go; VM &sect;11ai, &sect;11aj; masking.spec.ts</td></tr>
</table>
<div class="foot">FOX_Checklist &middot; area O</div>
</section>
Expand Down
Binary file modified docs/FOX_Checklist.pdf
Binary file not shown.
Loading
Loading