| 1 | // Package principalstore allocates identity records only. A principal is not |
| 2 | // membership, an authenticated session, or permission to read a document. |
| 3 | package principalstore |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "database/sql" |
| 8 | "embed" |
| 9 | "errors" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "time" |
| 13 | "unicode/utf8" |
| 14 | |
| 15 | "amadan.net/rastrillo/rastrillo/migrate" |
| 16 | ) |
| 17 | |
| 18 | //go:embed migrations/*.sql |
| 19 | var migrations embed.FS |
| 20 | |
| 21 | // Schema follows Docs and linkstore schemas. It remains unmounted until the |
| 22 | // combined migration, admission and rollback release has been reviewed. |
| 23 | var Schema = migrate.MustFromFS(migrations, "docs_principal") |
| 24 | |
| 25 | const HomeIDMin int64 = 1 << 52 |
| 26 | const HomeIDMax int64 = 1<<53 - 1 |
| 27 | |
| 28 | var ( |
| 29 | ErrRefused = errors.New("docs principal: refused") |
| 30 | ErrUnavailable = errors.New("docs principal: unavailable") |
| 31 | ) |
| 32 | |
| 33 | type Kind string |
| 34 | |
| 35 | const ( |
| 36 | Local Kind = "local" |
| 37 | Home Kind = "home" |
| 38 | ) |
| 39 | |
| 40 | type Principal struct { |
| 41 | ID int64 |
| 42 | Kind Kind |
| 43 | // Nil means no verified local address. Never substitute an empty or invented |
| 44 | // address in allowlists, sample creation or grant equality queries. |
| 45 | VerifiedAddress *string |
| 46 | } |
| 47 | |
| 48 | // HomeIdentity is supplied only after shared handoff verification and replay |
| 49 | // consumption. The store cannot authenticate a browser or an assertion. |
| 50 | type HomeIdentity struct{ Issuer, Subject string } |
| 51 | type MembershipRequest struct{ Issuer, Team, Subject string } |
| 52 | type Config struct { |
| 53 | Issuer, Team string |
| 54 | // RequireMembership consults the authoritative roster cache outside the SQL |
| 55 | // transaction. Missing/expired/untrusted snapshot is ErrUnavailable; a fresh |
| 56 | // complete snapshot lacking the person is ErrRefused. Success is a bounded |
| 57 | // observation, not a durable grant. Each later access must recheck authority. |
| 58 | RequireMembership func(context.Context, MembershipRequest) error |
| 59 | } |
| 60 | type Store struct { |
| 61 | db *sql.DB |
| 62 | cfg Config |
| 63 | } |
| 64 | |
| 65 | func New(db *sql.DB, cfg Config) (*Store, error) { |
| 66 | if db == nil || !origin(cfg.Issuer) || !opaque(cfg.Team, 64) { |
| 67 | return nil, ErrRefused |
| 68 | } |
| 69 | var foreignKeys int |
| 70 | if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil { |
| 71 | return nil, ErrUnavailable |
| 72 | } |
| 73 | if foreignKeys != 1 { |
| 74 | return nil, ErrRefused |
| 75 | } |
| 76 | return &Store{db: db, cfg: cfg}, nil |
| 77 | } |
| 78 | |
| 79 | func (s *Store) Person(ctx context.Context, id int64) (Principal, error) { |
| 80 | if id <= 0 || id > HomeIDMax { |
| 81 | return Principal{}, ErrRefused |
| 82 | } |
| 83 | return readPerson(s.db.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| 84 | } |
| 85 | func readPerson(row *sql.Row) (Principal, error) { |
| 86 | var p Principal |
| 87 | var address sql.NullString |
| 88 | err := row.Scan(&p.ID, &p.Kind, &address) |
| 89 | if errors.Is(err, sql.ErrNoRows) { |
| 90 | return Principal{}, ErrRefused |
| 91 | } |
| 92 | if err != nil { |
| 93 | return Principal{}, ErrUnavailable |
| 94 | } |
| 95 | switch p.Kind { |
| 96 | case Local: |
| 97 | if p.ID <= 0 || p.ID >= HomeIDMin || !address.Valid { |
| 98 | return Principal{}, ErrRefused |
| 99 | } |
| 100 | a := strings.ToLower(strings.TrimSpace(address.String)) |
| 101 | if a == "" { |
| 102 | return Principal{}, ErrRefused |
| 103 | } |
| 104 | p.VerifiedAddress = &a |
| 105 | case Home: |
| 106 | if p.ID < HomeIDMin || p.ID > HomeIDMax || address.Valid { |
| 107 | return Principal{}, ErrRefused |
| 108 | } |
| 109 | default: |
| 110 | return Principal{}, ErrRefused |
| 111 | } |
| 112 | return p, nil |
| 113 | } |
| 114 | |
| 115 | // Resolve reads either an existing local link or a Home-only identity. It does |
| 116 | // not create a record and returns no standing from a stored mapping. |
| 117 | func (s *Store) Resolve(ctx context.Context, issuer, subject string) (Principal, error) { |
| 118 | if issuer != s.cfg.Issuer || !opaque(subject, 512) { |
| 119 | return Principal{}, ErrRefused |
| 120 | } |
| 121 | var id int64 |
| 122 | var count int |
| 123 | err := s.db.QueryRowContext(ctx, `SELECT COALESCE(MIN(id),0),COUNT(*) FROM (SELECT person_id AS id FROM docs_identity_links WHERE issuer=? AND subject=? UNION ALL SELECT principal_id AS id FROM docs_home_identities WHERE issuer=? AND subject=?)`, issuer, subject, issuer, subject).Scan(&id, &count) |
| 124 | if err != nil { |
| 125 | return Principal{}, ErrUnavailable |
| 126 | } |
| 127 | if count != 1 { |
| 128 | return Principal{}, ErrRefused |
| 129 | } |
| 130 | return s.Person(ctx, id) |
| 131 | } |
| 132 | |
| 133 | // EnsureHome requires a current membership check even if another call has |
| 134 | // already created this Home-only identity. Use Resolve for identity-only reads. |
| 135 | func (s *Store) EnsureHome(ctx context.Context, identity HomeIdentity) (Principal, error) { |
| 136 | if identity.Issuer != s.cfg.Issuer || !opaque(identity.Subject, 512) || s.cfg.RequireMembership == nil { |
| 137 | return Principal{}, ErrRefused |
| 138 | } |
| 139 | if err := s.cfg.RequireMembership(ctx, MembershipRequest{identity.Issuer, s.cfg.Team, identity.Subject}); err != nil { |
| 140 | if errors.Is(err, ErrRefused) { |
| 141 | return Principal{}, ErrRefused |
| 142 | } |
| 143 | return Principal{}, ErrUnavailable |
| 144 | } |
| 145 | tx, err := s.db.BeginTx(ctx, nil) |
| 146 | if err != nil { |
| 147 | return Principal{}, ErrUnavailable |
| 148 | } |
| 149 | defer tx.Rollback() |
| 150 | // Take the writer lock before reading either mapping. The no-op write leaves |
| 151 | // the allocator untouched and makes concurrent first arrivals one winner. |
| 152 | if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=high_water WHERE singleton=0`); err != nil { |
| 153 | return Principal{}, ErrUnavailable |
| 154 | } |
| 155 | var local int |
| 156 | if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM docs_identity_links WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&local); err != nil { |
| 157 | return Principal{}, ErrUnavailable |
| 158 | } |
| 159 | if local != 0 { |
| 160 | return Principal{}, ErrRefused |
| 161 | } |
| 162 | var id int64 |
| 163 | err = tx.QueryRowContext(ctx, `SELECT principal_id FROM docs_home_identities WHERE issuer=? AND subject=?`, identity.Issuer, identity.Subject).Scan(&id) |
| 164 | if err == nil { |
| 165 | p, err := readPerson(tx.QueryRowContext(ctx, `SELECT p.id,p.kind,l.email FROM docs_principals p LEFT JOIN people l ON l.id=p.id WHERE p.id=?`, id)) |
| 166 | if err != nil { |
| 167 | return Principal{}, err |
| 168 | } |
| 169 | if p.Kind != Home { |
| 170 | return Principal{}, ErrRefused |
| 171 | } |
| 172 | if tx.Commit() != nil { |
| 173 | return Principal{}, ErrUnavailable |
| 174 | } |
| 175 | return p, nil |
| 176 | } |
| 177 | if !errors.Is(err, sql.ErrNoRows) { |
| 178 | return Principal{}, ErrUnavailable |
| 179 | } |
| 180 | if err = tx.QueryRowContext(ctx, `SELECT high_water FROM docs_home_allocator WHERE singleton=1`).Scan(&id); err != nil { |
| 181 | return Principal{}, ErrUnavailable |
| 182 | } |
| 183 | if id < HomeIDMin-1 || id >= HomeIDMax { |
| 184 | return Principal{}, ErrRefused |
| 185 | } |
| 186 | id++ |
| 187 | if _, err = tx.ExecContext(ctx, `UPDATE docs_home_allocator SET high_water=? WHERE singleton=1`, id); err != nil { |
| 188 | return Principal{}, ErrUnavailable |
| 189 | } |
| 190 | if _, err = tx.ExecContext(ctx, `INSERT INTO docs_principals(id,kind,created_at) VALUES(?,'home',?)`, id, time.Now().UTC()); err != nil { |
| 191 | return Principal{}, ErrUnavailable |
| 192 | } |
| 193 | if _, err = tx.ExecContext(ctx, `INSERT INTO docs_home_identities(issuer,subject,principal_id) VALUES(?,?,?)`, identity.Issuer, identity.Subject, id); err != nil { |
| 194 | return Principal{}, ErrUnavailable |
| 195 | } |
| 196 | if tx.Commit() != nil { |
| 197 | return Principal{}, ErrUnavailable |
| 198 | } |
| 199 | return Principal{ID: id, Kind: Home}, nil |
| 200 | } |
| 201 | func opaque(s string, max int) bool { |
| 202 | if s == "" || len(s) > max || !utf8.ValidString(s) { |
| 203 | return false |
| 204 | } |
| 205 | for _, r := range s { |
| 206 | if r <= 32 || r == 127 || r == '@' { |
| 207 | return false |
| 208 | } |
| 209 | } |
| 210 | return true |
| 211 | } |
| 212 | func origin(s string) bool { |
| 213 | u, e := url.Parse(s) |
| 214 | return e == nil && len(s) <= 2048 && u.Scheme == "https" && u.Host != "" && u.User == nil && u.Path == "" && u.RawQuery == "" && !u.ForceQuery && u.Fragment == "" && u.Opaque == "" && u.String() == s && !strings.ContainsAny(s, "\\\r\n\t ") |
| 215 | } |
| 216 | |