Files
8e50da8d0e feat(soc2): access-review CSV export + DR restore drill (#1)
Two SOC 2 controls as real, tested Go code against the existing Store/API:

- CC6.2/CC6.3 (access review): Store.AccessReview() joins shares→nodes into
  []ShareRow; GET /v1/drive/admin/access-review?org= streams it as CSV evidence
  and audits the export (access-review.export). Tested end-to-end via
  app.Fiber().Test: shares to two recipients, asserts CSV contains recipients +
  roles, asserts the export is in the audit trail.

- A1.3 (recovery): TestDRRestoreDrill uploads several files to a file:// vfs
  backend + fixed metadata DB, records node ids, closes the Store (node loss),
  opens a FRESH Store against the SAME substrate, and asserts every file
  downloads byte-identical and VerifyAudit()==0 — restore-from-substrate proven.

go build ./... && go test ./... pass under -mod=readonly; go mod tidy clean
(no new deps; stdlib encoding/csv only).

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-16 16:41:29 -07:00

201 lines
7.4 KiB
Go

// Package drive is Hanzo Drive: end-to-end post-quantum, Google-Drive-class
// storage. Content is content-addressed, ChaCha20-Poly1305-sealed blobs on a
// hanzoai/vfs backend (⇒ hanzoai/s3); metadata + ACLs are an encrypted SQLite
// per tenant. Sharing re-wraps a node's DEK to a recipient's ML-KEM identity —
// the bytes never re-encrypt and the server never sees plaintext.
package drive
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"time"
"github.com/hanzoai/sqlite3"
"github.com/hanzoai/vfs/pkg/backend"
"github.com/luxfi/age"
"golang.org/x/crypto/chacha20poly1305"
)
// Store is one tenant's Drive: a metadata DB + a content backend.
type Store struct {
db *sqlite3.Conn
content backend.Backend
}
// Node is a folder or file in the tree.
type Node struct {
ID, ParentID, Name, Kind, ContentKey string
Size int64
ModifiedAt int64
}
// Open opens a tenant Store: metaDSN is a hanzoai/sqlite3 DSN (use vfs=hanzo for
// the encrypted per-tenant DB); content is a vfs backend (s3:// in prod).
func Open(metaDSN string, content backend.Backend) (*Store, error) {
db, err := sqlite3.Open(metaDSN)
if err != nil {
return nil, err
}
if err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("drive: schema: %w", err)
}
return &Store{db: db, content: content}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func id() string { b := make([]byte, 16); rand.Read(b); return hex.EncodeToString(b) }
func now() int64 { return time.Now().Unix() }
// Mkdir creates a folder.
func (s *Store) Mkdir(parent, name string) (*Node, error) {
n := &Node{ID: id(), ParentID: parent, Name: name, Kind: "folder", ModifiedAt: now()}
st, _, err := s.db.Prepare(`INSERT INTO nodes(id,parent_id,name,kind,size,created_at,modified_at) VALUES(?,?,?,'folder',0,?,?)`)
if err != nil {
return nil, err
}
defer st.Close()
st.BindText(1, n.ID); st.BindText(2, parent); st.BindText(3, name); st.BindInt64(4, n.ModifiedAt); st.BindInt64(5, n.ModifiedAt)
if err := st.Exec(); err != nil {
return nil, err
}
return n, nil
}
// Upload seals content with a fresh per-node DEK, stores the content-addressed
// blob, records the node + version, and wraps the DEK to the owner. Returns the node.
func (s *Store) Upload(ctx context.Context, parent, name string, r io.Reader, owner age.Recipient) (*Node, error) {
plain, err := io.ReadAll(r)
if err != nil {
return nil, err
}
dek := make([]byte, chacha20poly1305.KeySize)
rand.Read(dek)
aead, _ := chacha20poly1305.New(dek)
nonce := make([]byte, aead.NonceSize())
rand.Read(nonce)
sealed := aead.Seal(nonce, nonce, plain, nil)
key := hex.EncodeToString(sha256.New().Sum(sealed)[:]) // content-addressed
if err := s.content.Put(ctx, key, sealed); err != nil {
return nil, fmt.Errorf("drive: content put: %w", err)
}
n := &Node{ID: id(), ParentID: parent, Name: name, Kind: "file", ContentKey: key, Size: int64(len(plain)), ModifiedAt: now()}
if err := s.db.Exec("BEGIN"); err != nil {
return nil, err
}
st, _, _ := s.db.Prepare(`INSERT INTO nodes(id,parent_id,name,kind,size,content_key,created_at,modified_at) VALUES(?,?,?,'file',?,?,?,?)`)
st.BindText(1, n.ID); st.BindText(2, parent); st.BindText(3, name); st.BindInt64(4, n.Size); st.BindText(5, key); st.BindInt64(6, n.ModifiedAt); st.BindInt64(7, n.ModifiedAt)
if err := st.Exec(); err != nil { st.Close(); s.db.Exec("ROLLBACK"); return nil, err }
st.Close()
vs, _, _ := s.db.Prepare(`INSERT INTO versions(id,node_id,content_key,size,created_at) VALUES(?,?,?,?,?)`)
vs.BindText(1, id()); vs.BindText(2, n.ID); vs.BindText(3, key); vs.BindInt64(4, n.Size); vs.BindInt64(5, n.ModifiedAt)
vs.Exec(); vs.Close()
if err := s.wrapDEK(n.ID, "owner", "owner", dek, owner); err != nil { s.db.Exec("ROLLBACK"); return nil, err }
return n, s.db.Exec("COMMIT")
}
// wrapDEK seals a node's DEK to a recipient and records the share (ACL row).
func (s *Store) wrapDEK(nodeID, recipient, role string, dek []byte, to age.Recipient) error {
var buf bytes.Buffer
w, err := age.Encrypt(&buf, to)
if err != nil {
return err
}
w.Write(dek); w.Close()
st, _, _ := s.db.Prepare(`INSERT OR REPLACE INTO shares(node_id,recipient,role,wrapped_dek,created_at) VALUES(?,?,?,?,?)`)
defer st.Close()
st.BindText(1, nodeID); st.BindText(2, recipient); st.BindText(3, role); st.BindBlob(4, buf.Bytes()); st.BindInt64(5, now())
return st.Exec()
}
// Share re-wraps an existing node's DEK to a new recipient — e2e, no re-encrypt.
// The caller must hold a share they can unwrap (their identity) to recover the DEK.
func (s *Store) Share(nodeID, asRecipient string, as age.Identity, to age.Recipient, toName, role string) error {
dek, err := s.unwrap(nodeID, asRecipient, as)
if err != nil {
return err
}
return s.wrapDEK(nodeID, toName, role, dek, to)
}
func (s *Store) unwrap(nodeID, recipient string, as age.Identity) ([]byte, error) {
st, _, _ := s.db.Prepare(`SELECT wrapped_dek FROM shares WHERE node_id=? AND recipient=?`)
defer st.Close()
st.BindText(1, nodeID); st.BindText(2, recipient)
if !st.Step() {
return nil, errors.New("drive: no share for recipient")
}
wrapped := st.ColumnRawBlob(0)
r, err := age.Decrypt(bytes.NewReader(wrapped), as)
if err != nil {
return nil, fmt.Errorf("drive: unwrap DEK: %w", err)
}
return io.ReadAll(r)
}
// Download fetches + decrypts a file for a recipient who holds a share.
func (s *Store) Download(ctx context.Context, nodeID, recipient string, as age.Identity) ([]byte, error) {
var ckey string
st, _, _ := s.db.Prepare(`SELECT content_key FROM nodes WHERE id=? AND trashed=0`)
st.BindText(1, nodeID)
if !st.Step() { st.Close(); return nil, errors.New("drive: node not found") }
ckey = st.ColumnText(0); st.Close()
dek, err := s.unwrap(nodeID, recipient, as)
if err != nil {
return nil, err
}
sealed, err := s.content.Get(ctx, ckey)
if err != nil {
return nil, err
}
aead, _ := chacha20poly1305.New(dek)
ns := aead.NonceSize()
return aead.Open(nil, sealed[:ns], sealed[ns:], nil)
}
// ShareRow is one access-grant row for the access-review export (SOC2 CC6.2/6.3:
// who has access to what, with which role).
type ShareRow struct {
NodeID string
NodeName string
Recipient string
Role string
}
// AccessReview returns every share grant joined to its node — the evidence a
// periodic access review (SOC2 CC6.2/CC6.3) is run against: each recipient, the
// node they can reach, and their role.
func (s *Store) AccessReview() ([]ShareRow, error) {
st, _, err := s.db.Prepare(`SELECT s.node_id, coalesce(n.name,''), s.recipient, s.role FROM shares s LEFT JOIN nodes n ON n.id = s.node_id ORDER BY s.node_id, s.recipient`)
if err != nil {
return nil, err
}
defer st.Close()
var out []ShareRow
for st.Step() {
out = append(out, ShareRow{NodeID: st.ColumnText(0), NodeName: st.ColumnText(1), Recipient: st.ColumnText(2), Role: st.ColumnText(3)})
}
return out, nil
}
// List returns the children of a folder.
func (s *Store) List(parent string) ([]Node, error) {
st, _, err := s.db.Prepare(`SELECT id,name,kind,size,coalesce(content_key,''),modified_at FROM nodes WHERE parent_id=? AND trashed=0 ORDER BY kind,name`)
if err != nil {
return nil, err
}
defer st.Close()
st.BindText(1, parent)
var out []Node
for st.Step() {
out = append(out, Node{ID: st.ColumnText(0), ParentID: parent, Name: st.ColumnText(1), Kind: st.ColumnText(2), Size: st.ColumnInt64(3), ContentKey: st.ColumnText(4), ModifiedAt: st.ColumnInt64(5)})
}
return out, nil
}