oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1// Package principalstore allocates identity records only. A principal is not
2// membership, an authenticated session, or permission to read a document.
3package principalstore
4
5import (
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
19var 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.
23var Schema = migrate.MustFromFS(migrations, "docs_principal")
24
25const HomeIDMin int64 = 1 << 52
26const HomeIDMax int64 = 1<<53 - 1
27
28var (
29 ErrRefused = errors.New("docs principal: refused")
30 ErrUnavailable = errors.New("docs principal: unavailable")
31)
32
33type Kind string
34
35const (
36 Local Kind = "local"
37 Home Kind = "home"
38)
39
40type 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.
50type HomeIdentity struct{ Issuer, Subject string }
51type MembershipRequest struct{ Issuer, Team, Subject string }
52type 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}
60type Store struct {
61 db *sql.DB
62 cfg Config
63}
64
65func 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
79func (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}
85func 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.
117func (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.
135func (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}
201func 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}
212func 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