Files
MindSift/internal/storage/visibility.go
T
sandrodachandClaude Opus 5.5 84395f3322
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
feat: organizations decide who sees whom; sharing only by owners
- Server admins manage organizations (one per user); new admin section,
  organization on user creation and invites, setup creates the first one
- Sharing modes private / organizations / open, enforced centrally
  (CanSee, CanShareWith) for directory, all shares and groups; unknown and
  invisible targets give the same error
- Losing visibility (org change, mode change, leaving a group) prunes shares,
  group memberships and task assignments; dry run warns before tightening
- Only owners share; editors can no longer reshare lists or remove others'
  shares; legacy task_lists.team_id and old /organizations endpoints removed
  (FLAW-06, FLAW-10, FLAW-24)
- Schema version: outdated databases are wiped and recreated on start
- Fix setup sample data (createSampleData was ignored); setup and admin
  use a shared ToggleSwitch instead of checkboxes
- Docs: sharing-and-permissions rewritten, ui-patterns, REST, DB, flaws

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:00:33 +01:00

290 lines
9.8 KiB
Go

package storage
import (
"database/sql"
"errors"
"fmt"
"strings"
"github.com/sandrodach/mindsift/internal/domain"
)
// Sharing modes (system_settings.sharing_mode). They decide who can see whom; every way of finding
// people (directory, search, sharing, groups) goes through CanSee / CanShareWith.
const (
SharingPrivate = "private" // nobody sees anybody, sharing and groups are off
SharingOrganizations = "organizations" // only members of the same organization see each other
SharingOpen = "open" // everybody sees everybody
)
// ErrSharingDisabled is returned for sharing or group actions while the server runs in private mode.
var ErrSharingDisabled = errors.New("Teilen ist auf diesem Server deaktiviert")
// ErrTargetNotFound is returned both for unknown people/groups and for ones the caller may not see,
// so nobody can probe which accounts exist.
var ErrTargetNotFound = errors.New("Benutzer oder Gruppe nicht gefunden")
func NormalizeSharingMode(mode string) string {
switch mode {
case SharingPrivate, SharingOpen:
return mode
}
return SharingOrganizations
}
func (s *Store) SharingMode() string {
sys, _ := s.GetSystemSettings()
if sys == nil {
return SharingOrganizations
}
return NormalizeSharingMode(sys.SharingMode)
}
func (s *Store) userOrganization(userID string) string {
var org string
_ = s.db.QueryRow(`SELECT organization_id FROM users WHERE id = ?`, userID).Scan(&org)
return org
}
// CanSee reports whether viewer may find target and share with it. Server admins follow the same rule;
// they manage everybody through the admin endpoints instead.
func (s *Store) CanSee(viewerID, targetID string) bool {
if viewerID == "" || targetID == "" {
return false
}
if viewerID == targetID {
return true
}
switch s.SharingMode() {
case SharingOpen:
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE id = ?`, targetID).Scan(&n)
return n > 0
case SharingPrivate:
return false
}
org := s.userOrganization(viewerID)
return org != "" && org == s.userOrganization(targetID)
}
// CanShareWith reports whether owner may share something with a user or group: users must be visible,
// groups must have owner as a member. Sharing is only ever done by the owner of the shared item.
func (s *Store) CanShareWith(ownerID, targetType, targetID string) bool {
if s.SharingMode() == SharingPrivate {
return false
}
if targetType == "team" {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM team_members WHERE team_id = ? AND user_id = ?`, targetID, ownerID).Scan(&n)
return n > 0
}
return ownerID != targetID && s.CanSee(ownerID, targetID)
}
// GetDiscoverableUsers returns everyone the viewer can see, the viewer included.
func (s *Store) GetDiscoverableUsers(viewerID string) ([]domain.User, error) {
var rows *sql.Rows
var err error
switch s.SharingMode() {
case SharingOpen:
rows, err = s.db.Query(`SELECT ` + userSelectCols + ` FROM users ORDER BY display_name COLLATE NOCASE`)
case SharingPrivate:
rows, err = s.db.Query(`SELECT `+userSelectCols+` FROM users WHERE id = ?`, viewerID)
default:
rows, err = s.db.Query(
`SELECT `+userSelectCols+` FROM users
WHERE id = ? OR (organization_id != '' AND organization_id = (SELECT organization_id FROM users WHERE id = ?))
ORDER BY display_name COLLATE NOCASE`,
viewerID, viewerID,
)
}
if err != nil {
return []domain.User{}, err
}
defer rows.Close()
users := make([]domain.User, 0)
for rows.Next() {
if u, err := scanUser(rows); err == nil {
users = append(users, *u)
}
}
return users, nil
}
// visibleSQL is the SQL form of CanSee for two user ID expressions under the given mode.
func visibleSQL(mode, a, b string) string {
switch mode {
case SharingOpen:
return "1"
case SharingPrivate:
return fmt.Sprintf("(%s = %s)", a, b)
}
return fmt.Sprintf(
`(%[1]s = %[2]s OR EXISTS (SELECT 1 FROM users ua JOIN users ub ON ub.organization_id = ua.organization_id
WHERE ua.id = %[1]s AND ub.id = %[2]s AND ua.organization_id != ''))`, a, b)
}
// PruneSharing removes everything that is no longer allowed under mode: group memberships across
// organizations, shares with people the owner cannot see, and shares with groups the owner is not in.
// Task assignments follow via PruneTaskAssignees. With dryRun nothing changes and only the number of
// affected memberships and shares is returned (used to warn before tightening the mode).
func (s *Store) PruneSharing(mode string, dryRun bool) (int64, error) {
mode = NormalizeSharingMode(mode)
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
var stmts []string
switch mode {
case SharingPrivate:
stmts = append(stmts, `DELETE FROM team_members`)
case SharingOrganizations:
// A group belongs to its creator's organization (teams.org_id)
stmts = append(stmts, `DELETE FROM team_members WHERE
(SELECT organization_id FROM users WHERE id = team_members.user_id) != (SELECT org_id FROM teams WHERE id = team_members.team_id)
OR (SELECT org_id FROM teams WHERE id = team_members.team_id) = ''`)
}
type shareTable struct{ table, owner, userCol, teamCol string }
tables := []shareTable{
{"doc_shares", "(SELECT user_id FROM documents WHERE id = doc_shares.doc_id)", "doc_shares.user_id", ""},
{"doc_team_shares", "(SELECT user_id FROM documents WHERE id = doc_team_shares.doc_id)", "", "doc_team_shares.team_id"},
{"task_list_shares", "(SELECT user_id FROM task_lists WHERE id = task_list_shares.task_list_id)", "task_list_shares.target_id", "task_list_shares.target_id"},
{"list_shares", "(SELECT user_id FROM lists WHERE id = list_shares.list_id)", "list_shares.target_id", "list_shares.target_id"},
{"workspace_folder_shares", "(SELECT owner_id FROM workspace_shared_folders WHERE id = workspace_folder_shares.folder_id)", "workspace_folder_shares.target_id", "workspace_folder_shares.target_id"},
}
for _, t := range tables {
hasType := t.userCol != "" && t.teamCol != ""
if t.userCol != "" {
cond := "NOT " + visibleSQL(mode, t.owner, t.userCol)
if hasType {
cond = "target_type = 'user' AND " + cond
}
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
}
if t.teamCol != "" {
cond := fmt.Sprintf(`NOT EXISTS (SELECT 1 FROM team_members WHERE team_id = %s AND user_id = %s)`, t.teamCol, t.owner)
if hasType {
cond = "target_type = 'team' AND " + cond
}
stmts = append(stmts, fmt.Sprintf(`DELETE FROM %s WHERE %s`, t.table, cond))
}
}
var affected int64
for _, q := range stmts {
res, err := tx.Exec(q)
if err != nil {
return 0, fmt.Errorf("prune sharing: %w", err)
}
n, _ := res.RowsAffected()
affected += n
}
// Groups without members disappear; groups that lost their last admin get one
if _, err := tx.Exec(`DELETE FROM teams WHERE id NOT IN (SELECT team_id FROM team_members)`); err != nil {
return 0, err
}
if _, err := tx.Exec(`UPDATE team_members SET role = 'admin' WHERE rowid IN (
SELECT MIN(rowid) FROM team_members GROUP BY team_id HAVING SUM(role = 'admin') = 0)`); err != nil {
return 0, err
}
if dryRun {
return affected, nil
}
if err := tx.Commit(); err != nil {
return 0, err
}
s.PruneTaskAssignees()
return affected, nil
}
// pruneSharingNow applies the current mode after a membership or organization change.
func (s *Store) pruneSharingNow() {
_, _ = s.PruneSharing(s.SharingMode(), false)
}
// --- ORGANIZATIONS (managed by server admins only) ---
func (s *Store) GetOrganizations() ([]domain.Organization, error) {
rows, err := s.db.Query(
`SELECT o.id, o.name, o.created_at, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id)
FROM organizations o ORDER BY o.name COLLATE NOCASE`,
)
if err != nil {
return []domain.Organization{}, err
}
defer rows.Close()
orgs := make([]domain.Organization, 0)
for rows.Next() {
var o domain.Organization
if rows.Scan(&o.ID, &o.Name, &o.CreatedAt, &o.MemberCount) == nil {
orgs = append(orgs, o)
}
}
return orgs, nil
}
func (s *Store) CreateOrganization(name string) (*domain.Organization, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, errors.New("Name der Organisation ist erforderlich")
}
org := &domain.Organization{ID: domain.NewID("org"), Name: name, CreatedAt: domain.NowISO()}
if _, err := s.db.Exec(`INSERT INTO organizations (id, name, created_at) VALUES (?, ?, ?)`, org.ID, org.Name, org.CreatedAt); err != nil {
return nil, err
}
return org, nil
}
func (s *Store) RenameOrganization(orgID, name string) error {
name = strings.TrimSpace(name)
if name == "" {
return errors.New("Name der Organisation ist erforderlich")
}
res, err := s.db.Exec(`UPDATE organizations SET name = ? WHERE id = ?`, name, orgID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Organisation nicht gefunden")
}
return nil
}
// DeleteOrganization removes the organization; its members end up without one and lose what they
// shared with each other.
func (s *Store) DeleteOrganization(orgID string) error {
if _, err := s.db.Exec(`UPDATE users SET organization_id = '' WHERE organization_id = ?`, orgID); err != nil {
return err
}
if _, err := s.db.Exec(`DELETE FROM organizations WHERE id = ?`, orgID); err != nil {
return err
}
s.pruneSharingNow()
return nil
}
// SetUserOrganization moves a user into an organization ("" = none). Leaving one drops the user's
// groups, shares and task assignments there.
func (s *Store) SetUserOrganization(userID, orgID string) error {
orgID = strings.TrimSpace(orgID)
if orgID != "" {
var n int
_ = s.db.QueryRow(`SELECT COUNT(*) FROM organizations WHERE id = ?`, orgID).Scan(&n)
if n == 0 {
return errors.New("Organisation nicht gefunden")
}
}
res, err := s.db.Exec(`UPDATE users SET organization_id = ? WHERE id = ?`, orgID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return errors.New("Benutzer nicht gefunden")
}
s.pruneSharingNow()
return nil
}