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
8 changes: 8 additions & 0 deletions cmd/fox/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ Stack:
checks: pass, fail, manual or n/a, with the evidence and the fix
hardening fix <check> | profile baseline|strict [--branch b] [--yes]
Fix a failing check, or every one a profile covers, after a preview (Enterprise)
mask [status] [--branch b] [--json]
Masking personal data: the rules on a branch, and the accounts masked
mask setup [--branch b] [--yes] | account <email> [--off] [--yes]
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)
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 Expand Up @@ -406,6 +412,8 @@ func main() {
accessCmd(os.Args[2:])
case "hardening":
hardeningCmd(os.Args[2:])
case "mask":
maskCmd(os.Args[2:])
case "app":
appCmd(os.Args[2:])
case "check":
Expand Down
252 changes: 252 additions & 0 deletions cmd/fox/maskcmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

package main

import (
"encoding/json"
"fmt"
"os"
"strings"

"github.com/thefoxbyte/foxbyte/internal/auth"
"github.com/thefoxbyte/foxbyte/internal/branch"
"github.com/thefoxbyte/foxbyte/internal/brand"
"github.com/thefoxbyte/foxbyte/internal/edition"
)

// maskCmd is `fox mask` (F5, point 4.6): masking personal data a reviewer
// confirmed, for the accounts an admin masks.
//
// fox mask [status] [--branch b] [--json]
// fox mask setup [--branch b] [--yes] (Enterprise)
// 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)
//
// Reading where masking stands is in every edition; setting it is Enterprise.
// Each setup, rule and account change is in the security log.
func maskCmd(args []string) {
if len(args) > 0 {
switch args[0] {
case "setup":
maskSetup(args[1:])
return
case "rule":
maskRule(args[1:])
return
case "account":
maskAccount(args[1:])
return
case "status":
args = args[1:]
}
}
st, err := branch.MaskingStatus(maskBranch(args))
must(err)
if hasFlag(args, "--json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
must(enc.Encode(st))
return
}
printMaskStatus(st)
}

func maskBranch(args []string) string {
if b := optValue(args, "--branch"); b != "" {
return b
}
return "main"
}

func printMaskStatus(st branch.MaskStatus) {
if !st.On {
fmt.Printf("Masking: not set up on this install.\n")
if st.Writes {
fmt.Printf(" %s mask setup explains what it does first\n", brand.CLI)
} else {
fmt.Printf(" Setting it up is in FoxByte Enterprise.\n")
}
return
}
fmt.Printf("Masking: on. Branch %s.\n", st.Branch)
if len(st.Pending) > 0 {
fmt.Printf(" Started before masking was set up, so not masking until they start again: %s\n", strings.Join(st.Pending, ", "))
}
fmt.Printf("\nRules (%d)\n", len(st.Rules))
ready := map[string]bool{}
for _, t := range st.Ready {
ready[t] = true
}
for _, r := range st.Rules {
view := r.Table
if r.Schema != "public" {
view = r.Schema + "__" + r.Table
}
state := "masked"
if !ready[view] {
state = "NOT MADE: a masked reader is refused the table"
}
fmt.Printf(" %-40s %-8s %-10s %s (%s, %s)\n", r.Where(), r.Rule, r.Category, state, r.By, r.At)
}
if len(st.Rules) == 0 {
fmt.Printf(" none: %s mask rule <table.column> sets one on a confirmed finding\n", brand.CLI)
}
fmt.Printf("\nMasked accounts (%d), on every branch they do not own\n", len(st.Accounts))
for _, a := range st.Accounts {
fmt.Printf(" %-40s by %s at %s\n", a.Email, a.By, a.At)
}
if len(st.Accounts) == 0 {
fmt.Printf(" none: nobody is masked until an admin masks them\n")
}
}

// maskSetup sets masking up on a branch: the key if the install has none, the
// databases restarted to mount it, then the roles and grants.
func maskSetup(args []string) {
requireFeature(edition.Masking)
b := maskBranch(args)
must(branch.MaskHAGuard())
// Started before the key existed, or on an older image: it starts again.
restart := branch.MaskNeedsRestart(b)
if !hasFlag(args, "--yes") {
fmt.Printf("This sets masking up on %s.\n\n"+
"It makes the install's masking key if it has none: 32 random bytes in\n"+
"%s, readable only by the database server and mounted read-only\n"+
"into each database. FoxByte never reads it back and never shows it.\n\n"+
"Then it makes, on %s, the masked readers' role, the schema their masks\n"+
"are read from and their rights on the tables. Nobody is masked yet:\n"+
"an admin masks accounts with `%s mask account`, and rules are set on\n"+
"confirmed findings with `%s mask rule`, which says what each costs.\n",
b, branch.MaskKeyDir(), b, brand.CLI, brand.CLI)
if restart {
fmt.Printf("\n%s restarts to mount the key: connections drop for 5–15 seconds\n"+
"while it comes back, and nothing is lost.\n", b)
}
fmt.Printf("\nRun again with --yes to go ahead.\n")
return
}
must(branch.EnsureMaskImage())
must(branch.EnsureMaskKey("default"))
if restart {
fmt.Printf("Restarting %s…\n", b)
if err := branch.RestartForMask(b); err != nil {
must(fmt.Errorf("%s did not come back with the key: %w", b, err))
}
if b == "main" {
// The standby is a primary in waiting: one without the key would
// promote a primary whose masked readers see nothing.
if err := branch.RestartStandby(); err != nil {
fmt.Printf("The HA standby could not be restarted, so it has no key: %v\n"+
"Rebuild it with `%s ha enable`.\n", err, brand.CLI)
}
}
}
must(branch.SetupMasking(b, cliActor()))
recordMask(auth.EvMaskSetup, b, "masking set up on "+b)
fmt.Printf("Done. `%s mask --branch %s` shows it.\n", brand.CLI, b)
}

// maskRule sets a rule, or takes one off. Before a rule is set it says what
// it costs (design, decision 9).
func maskRule(args []string) {
requireFeature(edition.Masking)
ps := positionals(args, "--branch", "--path")
if len(ps) < 1 || len(ps) > 2 {
fmt.Fprintf(os.Stderr, "usage: %s mask rule <[schema.]table.column> [%s] [--path $.a.b] [--branch b] [--yes]\n"+
" %s mask rule <[schema.]table.column> --off [--path $.a.b] [--branch b]\n",
brand.CLI, strings.Join(branch.MaskRules, "|"), brand.CLI)
os.Exit(2)
}
schema, table, column, err := splitColumn(ps[0])
must(err)
b, path := maskBranch(args), optValue(args, "--path")
where := branch.MaskRule{Schema: schema, Table: table, Column: column, Path: path}.Where()
if hasFlag(args, "--off") {
must(branch.DropMaskRule(b, schema, table, column, path, cliActor()))
recordMask(auth.EvMaskRule, b, fmt.Sprintf("rule taken off %s on %s", where, b))
fmt.Printf("Done: %s is no longer masked on %s.\n", where, b)
return
}
r := branch.MaskRule{Schema: schema, Table: table, Column: column, Path: path, By: cliActor()}
if len(ps) == 2 {
r.Rule = ps[1]
must(branch.CheckMaskRule(r.Rule, ""))
}
if !hasFlag(args, "--yes") {
rule := r.Rule
if rule == "" {
rule = "the default for its category"
}
fmt.Printf("This masks %s on %s, as %s, for every masked account.\n\n", where, b, rule)
fmt.Print(branch.MaskCost(r.Rule, path))
fmt.Printf("\nRun again with --yes to set it.\n")
return
}
must(branch.SetMaskRule(b, r))
recordMask(auth.EvMaskRule, b, fmt.Sprintf("rule %s set on %s on %s", orDefault(r.Rule), where, b))
fmt.Printf("Done. `%s mask --branch %s` shows it.\n", brand.CLI, b)
}

func orDefault(rule string) string {
if rule == "" {
return "(the category's default)"
}
return rule
}

// splitColumn reads [schema.]table.column.
func splitColumn(s string) (schema, table, column string, err error) {
parts := strings.Split(s, ".")
switch len(parts) {
case 2:
schema, table, column = "public", parts[0], parts[1]
case 3:
schema, table, column = parts[0], parts[1], parts[2]
default:
return "", "", "", fmt.Errorf("a column is named [schema.]table.column, not %q", s)
}
for _, p := range []string{schema, table, column} {
if p == "" {
return "", "", "", fmt.Errorf("a column is named [schema.]table.column, not %q", s)
}
}
return schema, table, column, nil
}

// maskAccount masks an account, or unmasks it. Their sessions on running
// branches end, so each next connection sees the change.
func maskAccount(args []string) {
requireFeature(edition.Masking)
ps := positionals(args)
if len(ps) != 1 {
fmt.Fprintf(os.Stderr, "usage: %s mask account <email> [--off] [--yes]\n", brand.CLI)
os.Exit(2)
}
email, off := strings.ToLower(ps[0]), hasFlag(args, "--off")
if !hasFlag(args, "--yes") {
if off {
fmt.Printf("This unmasks %s: they see real values again on every branch.\n", email)
} else {
fmt.Printf("This masks %s on every branch they do not own: they read the masks of\n"+
"marked tables, ordinary tables as they are, and cannot write to marked\n"+
"tables. An admin is never masked.\n", email)
}
fmt.Printf("Their open sessions end, so the next one sees the change.\n\nRun again with --yes to go ahead.\n")
return
}
must(branch.MaskAccount(email, cliActor(), off))
what := "masked"
if off {
what = "unmasked"
}
recordMask(auth.EvMaskAccount, "", what+" "+email)
fmt.Printf("Done: %s is %s.\n", email, what)
}

func recordMask(kind, b, detail string) {
if s := openStore(); s != nil {
s.Audit(kind, cliActor(), b, "", detail)
s.Close()
}
}
5 changes: 3 additions & 2 deletions docker/postgres/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ COPY --from=f1-build /out/ /
# container (internal/branch/images.go, PGImageRevision). An install keeps the
# image it first fetched, so a fox that needs something newer has to be able to
# tell: 2 is the first with bb_identity. Raise it with anything a fox comes to
# depend on, and raise PGImageRevision with it. 3 is the first with pgAudit.
LABEL dev.dbengine.image-revision="3"
# depend on, and raise PGImageRevision with it. 3 is the first with pgAudit;
# 4 with bb_identity 0.2, which has the masking hash (F5).
LABEL dev.dbengine.image-revision="4"

# Disposable point-in-time restore entrypoint (used by scripts/pitr_restore.sh).
COPY restore-entrypoint.sh /usr/local/bin/restore-entrypoint.sh
Expand Down
9 changes: 8 additions & 1 deletion docs/FOX_Checklist.html
Original file line number Diff line number Diff line change
Expand Up @@ -768,6 +768,14 @@ <h2><span class="al">O</span>Personal data &amp; compliance<span class="cnt"></s
<li class="ok">Each report in the security log; what it cannot claim stated in it</li>
<li class="ok">Applications: each registration, its account, its keys with age, state and what each asserted by branch, and findings to question; <code>--csv applications</code> (F1)</li>
<li class="lim">No unmask rights to review until masking (point 7); campaigns are 4.9 (Gate 2)</li></ul></td><td class="tb">enterprise/accessreview unit; VM &sect;11s; accessreview.spec.ts</td></tr>
<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="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>
</table>
<div class="foot">FOX_Checklist &middot; area O</div>
</section>
Expand All @@ -784,7 +792,6 @@ <h2><span class="al" style="background:#64748b">Z</span>Not started (roadmap)<sp
<tr data-st="todo"><td class="c"><i class="ck todo"></i></td><td class="id">—</td><td><b>More import sources: Cassandra, DynamoDB</b></td><td class="tb">I1–I8</td></tr>

<tr data-st="done"><td class="c"><i class="ck done"></i></td><td class="id">—</td><td><b>Release signatures</b> — done 22 Sep 2026</td><td class="tb">A9, M4</td></tr>
<tr data-st="todo"><td class="c"><i class="ck todo"></i></td><td class="id">—</td><td><b>Masking</b> (point 7, Enterprise; design proposed in <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; shadow tables, triggers and views; what a masked reader can and cannot reach; the lookup; a masked branch on a ZFS clone left with no real value in its files; the costs measured</li><li class="todo">Masked readers, rules on confirmed marks (partial, keyed hash, fake, empty), kept in shadow tables so no masking function runs at read time</li><li class="todo">The key outside every table; unmask and an audited lookup; every access path tested</li><li class="todo">Masked branches, closed until masked and verified</li></ul></td><td class="tb">O1, O2, H21</td></tr>
<tr data-st="todo"><td class="c"><i class="ck todo"></i></td><td class="id">—</td><td><b>Hosted cloud tier</b></td><td class="tb">—</td></tr>
<tr data-st="todo"><td class="c"><i class="ck todo"></i></td><td class="id">—</td><td><b>Networked storage disaggregation</b> (README roadmap; see the storage engine note on page 1)</td><td class="tb">C1, D1</td></tr>
</table>
Expand Down
Binary file modified docs/FOX_Checklist.pdf
Binary file not shown.
Loading
Loading