oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Package directorydata holds directory-owned team records. Its read methods
2// establish navigation choices only; applications still decide item access.
3package directorydata
4
5import (
6 "context"
7 "database/sql"
8 "embed"
9 "errors"
10 "net/url"
11 "strconv"
12 "strings"
13 "unicode"
14 "unicode/utf8"
15
16 "amadan.net/rastrillo/rastrillo/migrate"
17)
18
19//go:embed migrations/*.sql
20var migrations embed.FS
21
22// Schema belongs in the directory role's schema, never the credential schema.
23// It is not mounted by this package.
24var Schema = migrate.MustFromFS(migrations, "oficina_directory")
25
26var (
27 ErrInvalid = errors.New("invalid directory configuration or input")
28 ErrUnavailable = errors.New("directory unavailable")
29 ErrNotFound = errors.New("directory team not found")
30)
31
32// Sealer must authenticate ciphertext and separate purposes.
33type Sealer interface {
34 Seal(string, []byte) ([]byte, error)
35 Open(string, []byte) ([]byte, error)
36}
37
38// Route describes a stored destination for the caller's provisioning policy.
39type Route struct {
40 TeamID int64
41 TeamSQID, Label, App, Origin, ConsumerID string
42}
43
44// Config supplies the compiled application registry and destination policy.
45// ValidateRoute must not authorize a destination from request input. Production
46// can check the prescribed team hostname; an isolated pilot can use exact pins.
47type Config struct {
48 Apps []string
49 ValidateRoute func(Route) bool
50}
51
52type Team struct {
53 ID int64
54 SQID, Label, Name, Role string
55}
56
57type Choice struct {
58 Team
59 App, Origin, ConsumerID string
60}
61
62type Store struct {
63 db *sql.DB
64 sealer Sealer
65 apps map[string]bool
66 validateRoute func(Route) bool
67}
68
69func New(db *sql.DB, sealer Sealer, cfg Config) (*Store, error) {
70 if db == nil || sealer == nil || cfg.ValidateRoute == nil || len(cfg.Apps) == 0 || len(cfg.Apps) > 100 {
71 return nil, ErrInvalid
72 }
73 s := &Store{db: db, sealer: sealer, apps: make(map[string]bool, len(cfg.Apps)), validateRoute: cfg.ValidateRoute}
74 for _, app := range cfg.Apps {
75 if !validLabel(app) || s.apps[app] {
76 return nil, ErrInvalid
77 }
78 s.apps[app] = true
79 }
80 return s, nil
81}
82
83// SealTeamName prepares a name for an explicitly authorized caller-owned write
84// transaction. It neither inserts a team nor creates a membership. The caller
85// must reserve a new, immutable team ID before sealing and commit both together.
86func (s *Store) SealTeamName(teamID int64, name string) ([]byte, error) {
87 if teamID <= 0 || !validName(name) {
88 return nil, ErrInvalid
89 }
90 b, err := s.sealer.Seal(namePurpose(teamID), []byte(name))
91 if err != nil {
92 return nil, ErrUnavailable
93 }
94 return b, nil
95}
96
97// TeamForMember does not distinguish an unknown team from another person's team.
98func (s *Store) TeamForMember(ctx context.Context, subject, sqid string) (Team, error) {
99 if !validSubject(subject) || !alnum(sqid, 64) {
100 return Team{}, ErrNotFound
101 }
102 var t Team
103 var sealed []byte
104 err := s.db.QueryRowContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role FROM teams t JOIN memberships m ON m.team_id=t.id WHERE t.sqid=? AND m.person_ref=?`, sqid, subject).Scan(&t.ID, &t.SQID, &t.Label, &sealed, &t.Role)
105 if errors.Is(err, sql.ErrNoRows) {
106 return Team{}, ErrNotFound
107 }
108 if err != nil {
109 return Team{}, ErrUnavailable
110 }
111 if err = s.openTeam(&t, sealed); err != nil {
112 return Team{}, err
113 }
114 return t, nil
115}
116
117// Choices returns only current memberships with live instances, oldest team
118// first. Invalid stored configuration refuses the whole result, never a prefix.
119func (s *Store) Choices(ctx context.Context, subject, app string) ([]Choice, error) {
120 if !validSubject(subject) || !s.apps[app] {
121 return nil, ErrInvalid
122 }
123 rows, err := s.db.QueryContext(ctx, `SELECT t.id,t.sqid,t.label,t.sealed_name,m.role,i.app,i.origin,i.consumer_id,i.state FROM memberships m JOIN teams t ON t.id=m.team_id JOIN team_instances i ON i.team_id=t.id WHERE m.person_ref=? AND i.app=? ORDER BY t.id LIMIT 10001`, subject, app)
124 if err != nil {
125 return nil, ErrUnavailable
126 }
127 defer rows.Close()
128 choices := make([]Choice, 0)
129 count := 0
130 for rows.Next() {
131 count++
132 if count > 10000 {
133 return nil, ErrUnavailable
134 }
135 var c Choice
136 var sealed []byte
137 var state string
138 if rows.Scan(&c.ID, &c.SQID, &c.Label, &sealed, &c.Role, &c.App, &c.Origin, &c.ConsumerID, &state) != nil {
139 return nil, ErrUnavailable
140 }
141 if state != "live" && state != "pending" && state != "disabled" {
142 return nil, ErrUnavailable
143 }
144 if state != "live" {
145 continue
146 }
147 route := Route{TeamID: c.ID, TeamSQID: c.SQID, Label: c.Label, App: c.App, Origin: c.Origin, ConsumerID: c.ConsumerID}
148 if !ValidOrigin(c.Origin) || !validConsumer(c.ConsumerID) || !s.validateRoute(route) {
149 return nil, ErrUnavailable
150 }
151 if err = s.openTeam(&c.Team, sealed); err != nil {
152 return nil, err
153 }
154 choices = append(choices, c)
155 }
156 if rows.Err() != nil {
157 return nil, ErrUnavailable
158 }
159 return choices, nil
160}
161
162func (s *Store) openTeam(t *Team, sealed []byte) error {
163 if t.ID <= 0 || !alnum(t.SQID, 64) || !validLabel(t.Label) || (t.Role != "owner" && t.Role != "member") {
164 return ErrUnavailable
165 }
166 b, err := s.sealer.Open(namePurpose(t.ID), sealed)
167 if err != nil || !validName(string(b)) {
168 return ErrUnavailable
169 }
170 t.Name = string(b)
171 return nil
172}
173func namePurpose(id int64) string { return "directory.team-name/" + strconv.FormatInt(id, 10) }
174
175func alnum(s string, max int) bool {
176 if len(s) == 0 || len(s) > max {
177 return false
178 }
179 for _, c := range s {
180 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9') {
181 return false
182 }
183 }
184 return true
185}
186func validSubject(s string) bool {
187 if len(s) != 29 || !strings.HasPrefix(s, "pr_") {
188 return false
189 }
190 for _, c := range s[3:] {
191 if !(c >= 'a' && c <= 'z' || c >= '2' && c <= '7') {
192 return false
193 }
194 }
195 return true
196}
197
198// ValidLabel checks the shared DNS-label shape; reservation is a writer transaction.
199func ValidLabel(s string) bool { return validLabel(s) }
200
201func validLabel(s string) bool {
202 if len(s) == 0 || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
203 return false
204 }
205 for _, c := range s {
206 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
207 return false
208 }
209 }
210 return true
211}
212func validName(s string) bool {
213 if len(s) == 0 || len(s) > 256 || !utf8.ValidString(s) || strings.TrimSpace(s) == "" {
214 return false
215 }
216 for _, r := range s {
217 if unicode.IsControl(r) {
218 return false
219 }
220 }
221 return true
222}
223
224// ValidOrigin accepts only canonical HTTPS DNS origins without ports or URL components.
225// It checks syntax only; callers must also apply their provisioning policy.
226func ValidOrigin(s string) bool {
227 u, err := url.Parse(s)
228 if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery || u.Opaque != "" || u.Port() != "" || u.Host != strings.ToLower(u.Host) {
229 return false
230 }
231 parts := strings.Split(u.Host, ".")
232 if len(parts) < 2 {
233 return false
234 }
235 for _, p := range parts {
236 if len(p) == 0 || len(p) > 63 || p[0] == '-' || p[len(p)-1] == '-' {
237 return false
238 }
239 for _, c := range p {
240 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
241 return false
242 }
243 }
244 }
245 return len(u.Host) <= 253
246}
247
248func validConsumer(s string) bool {
249 if len(s) == 0 || len(s) > 64 {
250 return false
251 }
252 for _, c := range s {
253 if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
254 return false
255 }
256 }
257 return true
258}
259