Skip to content
Open
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
24 changes: 23 additions & 1 deletion architecture/webapp-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,11 @@ classDiagram
<<interface>>
+SignPut(ctx, key, size, ttl)
}
class Deleter {
<<interface>>
+Delete(ctx, key)
}
note for Deleter "internal/remote — the optional delete capability in the PutSigner mold, implemented by localBackend, s3Backend and gcsBackend. Deleting a missing key is not an error. httpBackend deliberately does NOT implement it: sync clients never delete remote objects — only the hub purges, and only its own root"
note for Backend "internal/remote — impls: localBackend (file://), s3Backend, gcsBackend, httpBackend (https:// hub), Prefixed wrapper"
note for Backend "Key handling is fallible now: Prefixed.key and localBackend.path RETURN AN ERROR (safeKey / store.UnderRoot) rather than concatenating, so a `..` key cannot walk out of a project's prefix or out of a file:// root — and Prefixed.List re-checks the STRIPPED key on the way out, since the prefix it removes is the only thing that was ever validated. The httpBackend client is origin-bound: the device token is keyed to settings.Server, SameOrigin is the one rule, refuseOffOriginRedirect is its CheckRedirect, a presign target must be https on a trusted origin (directTargetOK), and List drops keys failing journal.SafePath and clamps a negative Size. gcs SignPut now signs Content-Length too. Object carries Modified (S3 LastModified, GCS Updated, file mtime; zero where the backend has none) — RemoteSource.verify reads it to decide when a blob can no longer be rewritten by a presigned URL"

Expand Down Expand Up @@ -197,7 +202,7 @@ classDiagram
<<interface>>
+Role(org, email)
+Get +OrgsFor +ListInvites +ValidInvite +ManageURL
+Create +Rename +AddMember +SetRole +RemoveMember
+Create +Rename +Delete +AddMember +SetRole +RemoveMember
+CreateInvite +RevokeInvite +Redeem
}
class LocalDirectory {
Expand All @@ -208,6 +213,7 @@ classDiagram
-byID, invites
-seniority func() []string
+EvictMember(org, email)
+Delete(orgID) org row first, then its invites
+SetSeniority(f)
-heir(o) promotes an owner
-refresh re-reads the store before every decision
Expand All @@ -226,11 +232,20 @@ classDiagram
class OrgInvite {
+Token +Org +Creator +Expires +Uses
}
class deleteCascade {
<<Server, admin.go>>
deleteProject: tombstone, shares, cached volume, storage purge
handleOrgDelete: Dir.Delete first, then each project
audit log line + PostHog capture per delete
}
note for deleteCascade "Deleting a project TOMBSTONES the registry row FIRST — Project.Deleted/DeletedBy stay behind as the audit record, queryable via GET /api/projects?deleted=1 (same permission resolver as the live list, so a tombstone is visible to exactly whoever could see the project alive; hub admins additionally see tombstones of deleted orgs) — then revokes its share links, evicts the cached volume, and purges the storage prefix through remote.Deleter — best effort, logged, with a HasPrefix guard so a p-abc/ purge can never touch p-abcd/. Every live-project read path (Get, List, GetOrCreate name match, Update) skips tombstones, so the name is immediately reusable and no content route answers for one. Each delete also writes an `audit:` log line and a PostHog project_deleted / org_deleted event through Server.capture (a no-op unless analytics is configured). Org delete drops the org row before touching storage, so ErrManagedElsewhere from an external directory refuses the whole thing while everything is still intact. ProjectRepo.Delete (the hard remove) is now uncalled — a future tombstone purge would be its caller"

class ProjectDB {
-repo ProjectRepo
-byID
+Get +Create +Update +Rename +List
+Delete tombstones, never removes
+GetDeleted +ListDeleted
+SetCreator +SetDefault +SetTemplate
+SetPerm +ClearPerm
-refresh re-reads the store on reads AND mutators
Expand All @@ -242,6 +257,7 @@ classDiagram
+Template string
+Default string
+Perms map email→level
+Deleted time, +DeletedBy email — tombstone
}
class seedTemplate {
<<Server method>>
Expand Down Expand Up @@ -445,6 +461,12 @@ classDiagram
DirectUploader <|.. RemoteSource
RemoteSource o-- Backend : Prefixed(Root, projectID)
Backend <|-- PutSigner : optional capability
Backend <|-- Deleter : optional capability
Server *-- deleteCascade : DELETE project / org routes
deleteCascade ..> ProjectDB : Delete
deleteCascade ..> ShareDB : Revoke
deleteCascade ..> Directory : Delete
deleteCascade ..> Deleter : purge prefix

AuthProvider <|.. BuiltinAuth
AccountApprover <|.. BuiltinAuth
Expand Down
8 changes: 8 additions & 0 deletions internal/remote/gcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,12 @@ func (b *gcsBackend) Exists(ctx context.Context, key string) (bool, error) {
return false, err
}

func (b *gcsBackend) Delete(ctx context.Context, key string) error {
err := b.bucket.Object(b.key(key)).Delete(ctx)
if errors.Is(err, gcs.ErrObjectNotExist) {
return nil
}
return err
}

func (b *gcsBackend) Close() error { return b.client.Close() }
18 changes: 18 additions & 0 deletions internal/remote/local.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,22 @@ func (b *localBackend) Exists(_ context.Context, key string) (bool, error) {
return false, err
}

func (b *localBackend) Delete(_ context.Context, key string) error {
p, err := b.path(key)
if err != nil {
return err
}
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
return err
}
// Prune now-empty parent directories so purging a prefix leaves no husk;
// os.Remove refuses a non-empty directory, which is the stop condition.
for dir := filepath.Dir(p); dir != b.root; dir = filepath.Dir(dir) {
if os.Remove(dir) != nil {
break
}
}
return nil
}

func (b *localBackend) Close() error { return nil }
8 changes: 8 additions & 0 deletions internal/remote/remote.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ type PutSigner interface {
SignPut(ctx context.Context, key string, size int64, ttl time.Duration) (*SignedPut, error)
}

// Deleter is the optional delete capability, in the PutSigner mold: the hub
// uses it to purge a deleted project's objects from storage. Deleting a key
// that does not exist is not an error. Sync clients never delete remote
// objects — blobs and journals are append-only from a device's point of view.
type Deleter interface {
Delete(ctx context.Context, key string) error
}

type Backend interface {
Put(ctx context.Context, key string, r io.Reader, size int64) error
Get(ctx context.Context, key string) (io.ReadCloser, error)
Expand Down
10 changes: 10 additions & 0 deletions internal/remote/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,14 @@ func (b *s3Backend) Exists(ctx context.Context, key string) (bool, error) {
return false, err
}

// Delete removes one object. S3's DeleteObject is idempotent — deleting a
// missing key succeeds — which matches the Deleter contract.
func (b *s3Backend) Delete(ctx context.Context, key string) error {
_, err := b.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(b.bucket),
Key: aws.String(b.key(key)),
})
return err
}

func (b *s3Backend) Close() error { return nil }
105 changes: 102 additions & 3 deletions internal/webapp/admin.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
package webapp

import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"

"github.com/runbear-io/beardrive/internal/remote"
)

// Administration surfaces: project lifecycle (rename/delete by the owning
Expand Down Expand Up @@ -38,20 +44,113 @@ func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true})
}

// handleProjectDelete removes a project from the registry. Project admins
// only. Storage (blobs, journals) is intentionally left in place.
// handleProjectDelete removes a project from the registry and purges its
// storage prefix (blobs, journals). Project admins only.
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("project")
if _, ok := s.project(w, r, id, PermAdmin); !ok {
return
}
if err := s.Projects.Delete(id); err != nil {
if err := s.deleteProject(r.Context(), id, s.requestUser(r).Email); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}

// deleteProject tombstones the project (the registry row stays, marked
// deleted-by-whom-when — the audit record, queryable via /api/projects
// ?deleted=1), revokes its share links, drops its cached volume, and purges
// its objects from the storage root. The tombstone write is the operation;
// the purge is best effort — a storage error after the row is tombstoned
// leaves orphaned objects (the pre-purge status quo), never a half-deleted
// project. `by` is the deleting account's email.
func (s *Server) deleteProject(ctx context.Context, id, by string) error {
p, _ := s.Projects.Get(id)
if err := s.Projects.Delete(id, by); err != nil {
return err
}
log.Printf("audit: project deleted id=%s org=%s by=%s", id, p.Org, by)
s.capture(by, "project_deleted", map[string]any{"project": id, "org": p.Org})
if s.Shares != nil {
for _, sh := range s.Shares.List(id) {
s.Shares.Revoke(sh.Token)
}
}
s.volsMu.Lock()
delete(s.vols, id)
s.volsMu.Unlock()
if err := s.purgeStorage(ctx, id); err != nil {
log.Printf("project %s deleted but storage purge failed (objects remain): %v", id, err)
}
return nil
}

// purgeStorage deletes every object under the project's storage prefix. A
// Root without the delete capability keeps the old behavior: the id is
// retired, the objects stay for out-of-band cleanup.
func (s *Server) purgeStorage(ctx context.Context, id string) error {
d, ok := s.Root.(remote.Deleter)
if !ok {
return nil
}
objs, err := s.Root.List(ctx, id+"/")
if err != nil {
return err
}
var firstErr error
for _, o := range objs {
// List answers by string prefix, so "p-abc/" can surface a sibling
// like "p-abcd/x" on some backends — never delete outside the id.
if !strings.HasPrefix(o.Key, id+"/") {
continue
}
if err := d.Delete(ctx, o.Key); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}

// handleOrgDelete deletes an organization: every project it owns (registry
// and storage, via deleteProject) and then the org itself. Owners only. The
// org row goes first — it settles that this directory owns its orgs at all
// (ErrManagedElsewhere) before anything irreversible touches storage.
func (s *Server) handleOrgDelete(w http.ResponseWriter, r *http.Request) {
orgID := r.PathValue("org")
by, ok := s.requireOwner(w, r, orgID)
if !ok {
return
}
if err := s.Dir.Delete(orgID); err != nil {
s.writeDirErr(w, orgID, err)
return
}
deleted := 0
var failed []string
if s.Projects != nil {
for _, p := range s.Projects.List() {
if p.Org != orgID {
continue
}
if err := s.deleteProject(r.Context(), p.ID, by); err != nil {
log.Printf("org %s deleted but project %s was not: %v", orgID, p.ID, err)
failed = append(failed, p.ID)
continue
}
deleted++
}
}
log.Printf("audit: org deleted id=%s by=%s projects=%d", orgID, by, deleted)
s.capture(by, "org_deleted", map[string]any{"org": orgID, "projects": deleted})
if len(failed) > 0 {
http.Error(w, fmt.Sprintf("organization deleted, but these projects were not: %s",
strings.Join(failed, ", ")), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}

// handleOrgShares lists every live public share across the org's projects,
// so an owner can audit "what have we made public?" in one place. Any org
// member may view; only owners revoke (via the existing per-share endpoint).
Expand Down
2 changes: 1 addition & 1 deletion internal/webapp/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ func checkToken(t authToken) error { return storable(t.Hash, t.User, t.Device) }

func checkProject(p Project) error {
if err := storable(p.ID, p.Name, p.Org, p.Description, p.Icon,
p.Creator, p.Template, p.Default); err != nil {
p.Creator, p.Template, p.Default, p.DeletedBy); err != nil {
return err
}
return storableMap(p.Perms)
Expand Down
8 changes: 7 additions & 1 deletion internal/webapp/db_conformance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ func TestMetaStoreConformance(t *testing.T) {
if err := projects.SetPerm(p2.ID, "doomed@x.io", PermAdmin); err != nil {
t.Fatal(err)
}
if err := projects.Delete(p2.ID); err != nil {
if err := projects.Delete(p2.ID, "boss@x.io"); err != nil {
t.Fatal(err)
}
// per-project permissions ride along with the project record
Expand Down Expand Up @@ -292,6 +292,12 @@ func TestMetaStoreConformance(t *testing.T) {
if _, ok := projects2.Get(p2.ID); ok {
t.Fatal("deleted project (and its grants) came back after reload")
}
// The tombstone itself survives the reload: the audit record of
// who deleted what, when.
ts, ok := projects2.GetDeleted(p2.ID)
if !ok || ts.DeletedBy != "boss@x.io" || ts.Deleted.IsZero() {
t.Fatalf("tombstone lost across reload: %+v (ok=%v)", ts, ok)
}

orgs2, _ := NewOrgDB(st2.Orgs())
ro, ok := orgs2.Get(org.ID)
Expand Down
31 changes: 19 additions & 12 deletions internal/webapp/db_sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,11 @@ func (s *sqlMetaStore) migrate() error {
"creator": `TEXT NOT NULL DEFAULT ''`,
"default_level": `TEXT NOT NULL DEFAULT ''`,
"template": `TEXT NOT NULL DEFAULT ''`,
"deleted": `TEXT NOT NULL DEFAULT ''`,
"deleted_by": `TEXT NOT NULL DEFAULT ''`,
}, map[string]string{
"default_level": "it silently re-opens every restricted project to its whole organization",
"deleted": "it resurrects every deleted project as live, with its storage already purged",
}); err != nil {
return err
}
Expand Down Expand Up @@ -507,21 +510,21 @@ func (r *sqlProjectRepo) Version() (string, error) { return r.s.version(regProje

func (r *sqlProjectRepo) Load() ([]Project, error) {
rows, err := r.s.db.Query(
`SELECT id, name, org, created, description, icon, creator, default_level, template FROM projects`)
`SELECT id, name, org, created, description, icon, creator, default_level, template, deleted, deleted_by FROM projects`)
if err != nil {
return nil, err
}
byID := map[string]*Project{}
var order []string
for rows.Next() {
var p Project
var created string
var created, deleted string
if err := rows.Scan(&p.ID, &p.Name, &p.Org, &created,
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template); err != nil {
&p.Description, &p.Icon, &p.Creator, &p.Default, &p.Template, &deleted, &p.DeletedBy); err != nil {
rows.Close()
return nil, err
}
p.Created = tdec(created)
p.Created, p.Deleted = tdec(created), tdec(deleted)
byID[p.ID] = &p
order = append(order, p.ID)
}
Expand Down Expand Up @@ -567,12 +570,14 @@ func (r *sqlProjectRepo) Put(p Project) error {
}
return r.s.inTx(regProjects, func(tx *sql.Tx) error {
if _, err := tx.Exec(r.s.q(
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
VALUES (?,?,?,?,?,?,?,?,?)
`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,deleted,deleted_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon,
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`),
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template); err != nil {
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
deleted=excluded.deleted, deleted_by=excluded.deleted_by`),
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template,
tenc(p.Deleted), p.DeletedBy); err != nil {
return err
}
if _, err := tx.Exec(r.s.q(`DELETE FROM project_perms WHERE project = ?`), p.ID); err != nil {
Expand All @@ -594,12 +599,14 @@ func (r *sqlProjectRepo) PutMeta(p Project) error {
if err := checkProject(p); err != nil {
return err
}
return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template)
VALUES (?,?,?,?,?,?,?,?,?)
return r.w.exec(`INSERT INTO projects (id,name,org,created,description,icon,creator,default_level,template,deleted,deleted_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name=excluded.name, org=excluded.org, created=excluded.created,
description=excluded.description, icon=excluded.icon,
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template)
creator=excluded.creator, default_level=excluded.default_level, template=excluded.template,
deleted=excluded.deleted, deleted_by=excluded.deleted_by`,
p.ID, p.Name, p.Org, tenc(p.Created), p.Description, p.Icon, p.Creator, p.Default, p.Template,
tenc(p.Deleted), p.DeletedBy)
}

// PutPerm writes one grant row. An empty level removes it.
Expand Down
4 changes: 4 additions & 0 deletions internal/webapp/directory.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ type Directory interface {
// ---- writes (ErrManagedElsewhere when the directory is read-only) ----
Create(name, ownerEmail string) (Org, error)
Rename(orgID, name string) error
// Delete removes the org and its invites. Its projects are the hub's to
// cascade (registry rows and storage) — the directory knows nothing of
// project storage.
Delete(orgID string) error
AddMember(orgID, email, role string) error
SetRole(orgID, email, role string) error
RemoveMember(orgID, email string) error
Expand Down
1 change: 1 addition & 0 deletions internal/webapp/directory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type readOnlyDir struct{ Directory }

func (readOnlyDir) Create(string, string) (Org, error) { return Org{}, ErrManagedElsewhere }
func (readOnlyDir) Rename(string, string) error { return ErrManagedElsewhere }
func (readOnlyDir) Delete(string) error { return ErrManagedElsewhere }
func (readOnlyDir) AddMember(string, string, string) error { return ErrManagedElsewhere }
func (readOnlyDir) SetRole(string, string, string) error { return ErrManagedElsewhere }
func (readOnlyDir) RemoveMember(string, string) error { return ErrManagedElsewhere }
Expand Down
Loading
Loading