| 1 | // Package directorydata holds directory-owned team records. Its read methods |
| 2 | // establish navigation choices only; applications still decide item access. |
| 3 | package directorydata |
| 4 | |
| 5 | import ( |
| 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 |
| 20 | var 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. |
| 24 | var Schema = migrate.MustFromFS(migrations, "oficina_directory") |
| 25 | |
| 26 | var ( |
| 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. |
| 33 | type 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. |
| 39 | type 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. |
| 47 | type Config struct { |
| 48 | Apps []string |
| 49 | ValidateRoute func(Route) bool |
| 50 | } |
| 51 | |
| 52 | type Team struct { |
| 53 | ID int64 |
| 54 | SQID, Label, Name, Role string |
| 55 | } |
| 56 | |
| 57 | type Choice struct { |
| 58 | Team |
| 59 | App, Origin, ConsumerID string |
| 60 | } |
| 61 | |
| 62 | type Store struct { |
| 63 | db *sql.DB |
| 64 | sealer Sealer |
| 65 | apps map[string]bool |
| 66 | validateRoute func(Route) bool |
| 67 | } |
| 68 | |
| 69 | func 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. |
| 86 | func (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. |
| 98 | func (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. |
| 119 | func (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 | |
| 162 | func (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 | } |
| 173 | func namePurpose(id int64) string { return "directory.team-name/" + strconv.FormatInt(id, 10) } |
| 174 | |
| 175 | func 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 | } |
| 186 | func 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. |
| 199 | func ValidLabel(s string) bool { return validLabel(s) } |
| 200 | |
| 201 | func 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 | } |
| 212 | func 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. |
| 226 | func 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 | |
| 248 | func 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 | |