diff --git a/CHANGELOG.md b/CHANGELOG.md index f02e98e..8c7dd89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ # Changelog +## v0.1.0-preview.2 — 2026-08-17 + +- Add storage-neutral organizations, teams, projects, environments, services, + single-use invitations, and independently scoped access roles. +- Separate platform-level authentication roles from organization data access. +- Add expiring break-glass grants with transactional organization-visible audit + events and a no-CGO SQLite implementation. +- Keep `v0.1.0-preview.1` immutable; applications adopt these additive packages + by explicitly selecting Preview 2. + ## v0.1.0-preview.1 — 2026-08-16 - Establish independent request metadata, logging, browser security, abuse, diff --git a/README.md b/README.md index e19f9c8..ac0c130 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # Gamertan Web Foundations -> Status: `v0.1.0-preview.1` public preview. APIs may change before a stable +> Status: `v0.1.0-preview.2` public preview. APIs may change before a stable > release; Linux is the maintained release platform. Small, composable Go packages for the unglamorous boundaries of a careful web @@ -23,14 +23,14 @@ Pin the preview in an application module, then import only the packages that application needs: ```bash -go get gamertan.com/web@v0.1.0-preview.1 +go get gamertan.com/web@v0.1.0-preview.2 go mod verify ``` An application may also name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.1 +go get gamertan.com/web/requestmeta@v0.1.0-preview.2 ``` The version belongs to the `gamertan.com/web` module. Go compiles and links @@ -56,8 +56,11 @@ without turning that portability into a maintained compatibility claim. rate limits. - [`abuse`](abuse): application-classified request abuse with pluggable persistence. - [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite): - passwords, sessions, permissions, cookies, - and a no-CGO SQLite adapter. + passwords, sessions, platform-level permissions, cookies, and a no-CGO + SQLite adapter. +- [`organizations`](organizations) and [`access`](access): organizations, + teams, invitations, resource hierarchy, scoped roles, and audited temporary + access without turning platform operation into tenant-data access. - [`analytics`](analytics): safe and sensitive aggregate projections over request records. diff --git a/access/access.go b/access/access.go new file mode 100644 index 0000000..78a6988 --- /dev/null +++ b/access/access.go @@ -0,0 +1,270 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package access defines organization-scoped role bindings and audited, +// short-lived break-glass authorization. +package access + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "regexp" + "sort" + "strings" + "time" +) + +var ( + idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) + namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`) +) + +type SubjectKind string + +const ( + User SubjectKind = "user" + Team SubjectKind = "team" +) + +type Scope struct { + OrganizationID string + ProjectID string + EnvironmentID string + ServiceID string +} + +func (scope Scope) Validate() error { + if !idPattern.MatchString(scope.OrganizationID) || scope.ProjectID != "" && !idPattern.MatchString(scope.ProjectID) || scope.EnvironmentID != "" && !idPattern.MatchString(scope.EnvironmentID) || scope.ServiceID != "" && !idPattern.MatchString(scope.ServiceID) { + return errors.New("access: invalid scope") + } + if scope.EnvironmentID != "" && scope.ProjectID == "" || scope.ServiceID != "" && scope.EnvironmentID == "" { + return errors.New("access: incomplete scope hierarchy") + } + return nil +} + +func (scope Scope) contains(requested Scope) bool { + if scope.OrganizationID != requested.OrganizationID { + return false + } + for _, pair := range [][2]string{{scope.ProjectID, requested.ProjectID}, {scope.EnvironmentID, requested.EnvironmentID}, {scope.ServiceID, requested.ServiceID}} { + if pair[0] != "" && pair[0] != pair[1] { + return false + } + } + return true +} + +type Binding struct { + ID string + SubjectKind SubjectKind + SubjectID string + Role string + Scope Scope + GrantedBy string + GrantedAt time.Time +} + +type Policy struct { + Roles map[string]string + Permissions map[string]string + Grants map[string][]string +} + +func (policy Policy) Validate() error { + if len(policy.Roles) == 0 || len(policy.Roles) > 1000 || len(policy.Permissions) == 0 || len(policy.Permissions) > 10000 || len(policy.Grants) > 1000 { + return errors.New("access: invalid policy size") + } + for name, description := range policy.Roles { + if !namePattern.MatchString(name) || !text(description, 512, true) { + return errors.New("access: invalid role") + } + } + for name, description := range policy.Permissions { + if !namePattern.MatchString(name) || !text(description, 512, true) { + return errors.New("access: invalid permission") + } + } + for role, permissions := range policy.Grants { + if _, ok := policy.Roles[role]; !ok || len(permissions) > 10000 { + return errors.New("access: invalid role grant") + } + for _, permission := range permissions { + if _, ok := policy.Permissions[permission]; !ok { + return errors.New("access: role references unknown permission") + } + } + } + return nil +} + +type BreakGlass struct { + ID, OrganizationID, UserID, Permission, Reason string + CreatedAt, ExpiresAt time.Time +} + +type AuditEvent struct { + ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string + CreatedAt time.Time +} + +type Repository interface { + SeedAccessPolicy(context.Context, Policy) error + Grant(context.Context, Binding) error + Revoke(context.Context, string, string, time.Time) error + EffectiveBindings(context.Context, string, string) ([]Binding, error) + CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error + ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) + AppendAccessAudit(context.Context, AuditEvent) error + AccessAudit(context.Context, string, int) ([]AuditEvent, error) +} + +type Options struct { + Random io.Reader + Now func() time.Time +} + +type Service struct { + repository Repository + policy Policy + random io.Reader + now func() time.Time +} + +func New(repository Repository, policy Policy, options Options) (*Service, error) { + if repository == nil { + return nil, errors.New("access: repository is required") + } + if err := policy.Validate(); err != nil { + return nil, err + } + if options.Random == nil { + options.Random = rand.Reader + } + if options.Now == nil { + options.Now = time.Now + } + return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil +} + +func (service *Service) Seed(ctx context.Context) error { + return service.repository.SeedAccessPolicy(ctx, service.policy) +} + +type Grant struct { + SubjectKind SubjectKind + SubjectID string + Role string + Scope Scope + GrantedBy string +} + +func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error) { + if (input.SubjectKind != User && input.SubjectKind != Team) || !idPattern.MatchString(input.SubjectID) || !idPattern.MatchString(input.GrantedBy) { + return Binding{}, errors.New("access: invalid binding subject") + } + if _, ok := service.policy.Roles[input.Role]; !ok { + return Binding{}, errors.New("access: unknown role") + } + if err := input.Scope.Validate(); err != nil { + return Binding{}, err + } + id, err := randomID(service.random) + if err != nil { + return Binding{}, err + } + binding := Binding{ID: id, SubjectKind: input.SubjectKind, SubjectID: input.SubjectID, Role: input.Role, Scope: input.Scope, GrantedBy: input.GrantedBy, GrantedAt: service.now().UTC()} + if err = service.repository.Grant(ctx, binding); err != nil { + return Binding{}, err + } + return binding, nil +} + +type Decision struct { + Allowed bool + Source string + Role string +} + +func (service *Service) Authorize(ctx context.Context, userID string, scope Scope, permission string) (Decision, error) { + if !idPattern.MatchString(userID) || !namePattern.MatchString(permission) { + return Decision{}, errors.New("access: invalid authorization request") + } + if err := scope.Validate(); err != nil { + return Decision{}, err + } + if _, ok := service.policy.Permissions[permission]; !ok { + return Decision{}, errors.New("access: unknown permission") + } + bindings, err := service.repository.EffectiveBindings(ctx, scope.OrganizationID, userID) + if err != nil { + return Decision{}, err + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) + for _, binding := range bindings { + if !binding.Scope.contains(scope) { + continue + } + for _, granted := range service.policy.Grants[binding.Role] { + if granted == permission { + return Decision{Allowed: true, Source: "role", Role: binding.Role}, nil + } + } + } + breakGlass, err := service.repository.ActiveBreakGlass(ctx, scope.OrganizationID, userID, service.now().UTC()) + if err != nil { + return Decision{}, err + } + for _, grant := range breakGlass { + if grant.Permission == permission { + return Decision{Allowed: true, Source: "break_glass"}, nil + } + } + return Decision{}, nil +} + +func (service *Service) ActivateBreakGlass(ctx context.Context, organizationID, userID, permission, reason, requestID string, lifetime time.Duration) (BreakGlass, error) { + if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !namePattern.MatchString(permission) || !text(strings.TrimSpace(reason), 1024, false) || !text(requestID, 128, true) || lifetime < time.Minute || lifetime > time.Hour { + return BreakGlass{}, errors.New("access: invalid break-glass request") + } + if _, ok := service.policy.Permissions[permission]; !ok { + return BreakGlass{}, errors.New("access: unknown permission") + } + id, err := randomID(service.random) + if err != nil { + return BreakGlass{}, err + } + auditID, err := randomID(service.random) + if err != nil { + return BreakGlass{}, err + } + now := service.now().UTC() + grant := BreakGlass{ID: id, OrganizationID: organizationID, UserID: userID, Permission: permission, Reason: strings.TrimSpace(reason), CreatedAt: now, ExpiresAt: now.Add(lifetime)} + audit := AuditEvent{ID: auditID, OrganizationID: organizationID, ActorUserID: userID, Action: "break_glass.activate", ResourceType: "organization", ResourceID: organizationID, RequestID: requestID, Summary: "Temporary emergency access activated", CreatedAt: now} + if err = service.repository.CreateBreakGlass(ctx, grant, audit); err != nil { + return BreakGlass{}, err + } + return grant, nil +} + +func (service *Service) Audit(ctx context.Context, organizationID string, limit int) ([]AuditEvent, error) { + if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 { + return nil, errors.New("access: invalid audit query") + } + return service.repository.AccessAudit(ctx, organizationID, limit) +} + +func randomID(random io.Reader) (string, error) { + value := make([]byte, 18) + if _, err := io.ReadFull(random, value); err != nil { + return "", fmt.Errorf("access: secure randomness unavailable: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func text(value string, limit int, emptyOK bool) bool { + return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/access/access_test.go b/access/access_test.go new file mode 100644 index 0000000..93b61b8 --- /dev/null +++ b/access/access_test.go @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MPL-2.0 + +package access + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestScopedRoleAndBreakGlass(t *testing.T) { + now := time.Unix(1000, 0).UTC() + repository := &repositoryStub{} + policy := Policy{Roles: map[string]string{"viewer": "Read safe telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}} + service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + scope := Scope{OrganizationID: "org-12345678", ProjectID: "project-12345678"} + binding, err := service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: scope, GrantedBy: "user-87654321"}) + if err != nil { + t.Fatal(err) + } + repository.bindings = []Binding{binding} + decision, err := service.Authorize(t.Context(), "user-12345678", Scope{OrganizationID: scope.OrganizationID, ProjectID: scope.ProjectID, EnvironmentID: "env-12345678"}, "telemetry.read") + if err != nil || !decision.Allowed || decision.Source != "role" { + t.Fatalf("decision=%+v err=%v", decision, err) + } + decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read") + if err != nil || decision.Allowed { + t.Fatalf("unexpected sensitive decision=%+v err=%v", decision, err) + } + grant, err := service.ActivateBreakGlass(t.Context(), scope.OrganizationID, "user-12345678", "telemetry.sensitive.read", "Investigate active incident", "request-12345678", 15*time.Minute) + if err != nil { + t.Fatal(err) + } + repository.breakGlass = []BreakGlass{grant} + decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read") + if err != nil || !decision.Allowed || decision.Source != "break_glass" { + t.Fatalf("break-glass decision=%+v err=%v", decision, err) + } +} + +func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) { + policy := Policy{Roles: map[string]string{"viewer": ""}, Permissions: map[string]string{"telemetry.read": ""}, Grants: map[string][]string{"viewer": {"telemetry.read"}}} + service, err := New(&repositoryStub{}, policy, Options{Random: strings.NewReader(strings.Repeat("x", 256))}) + if err != nil { + t.Fatal(err) + } + if _, err = service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: Scope{OrganizationID: "org-12345678", EnvironmentID: "env-12345678"}, GrantedBy: "user-87654321"}); err == nil { + t.Fatal("incomplete hierarchy accepted") + } + if _, err = service.ActivateBreakGlass(t.Context(), "org-12345678", "user-12345678", "telemetry.read", "reason", "", 2*time.Hour); err == nil { + t.Fatal("unbounded break-glass lifetime accepted") + } +} + +type repositoryStub struct { + bindings []Binding + breakGlass []BreakGlass +} + +func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil } +func (*repositoryStub) Grant(context.Context, Binding) error { return nil } +func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error { return nil } +func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) { + return repository.bindings, nil +} +func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error { + repository.breakGlass = []BreakGlass{grant} + return nil +} +func (repository *repositoryStub) ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) { + return repository.breakGlass, nil +} +func (*repositoryStub) AppendAccessAudit(context.Context, AuditEvent) error { return nil } +func (*repositoryStub) AccessAudit(context.Context, string, int) ([]AuditEvent, error) { + return nil, nil +} diff --git a/authsqlite/access.go b/authsqlite/access.go new file mode 100644 index 0000000..10e0da1 --- /dev/null +++ b/authsqlite/access.go @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "gamertan.com/web/access" +) + +func (store *Store) SeedAccessPolicy(ctx context.Context, policy access.Policy) error { + if err := policy.Validate(); err != nil { + return err + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + for name, description := range policy.Roles { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_roles(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil { + return err + } + } + for name, description := range policy.Permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_permissions(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil { + return err + } + } + for role, permissions := range policy.Grants { + if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_access_role_permissions WHERE role_name=?`, role); err != nil { + return err + } + for _, permission := range permissions { + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_role_permissions(role_name,permission_name) VALUES(?,?)`, role, permission); err != nil { + return err + } + } + } + return tx.Commit() +} + +func (store *Store) Grant(ctx context.Context, binding access.Binding) error { + if !opaqueID(binding.ID) || (binding.SubjectKind != access.User && binding.SubjectKind != access.Team) || !opaqueID(binding.SubjectID) || !safeName(binding.Role) || binding.Scope.Validate() != nil || !opaqueID(binding.GrantedBy) || binding.GrantedAt.IsZero() { + return errors.New("authsqlite: invalid access binding") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var exists int + query := `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'` + if binding.SubjectKind == access.Team { + query = `SELECT COUNT(*) FROM gwf_teams WHERE organization_id=? AND id=?` + } + if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil { + return err + } + if exists != 1 { + return errors.New("authsqlite: access subject is not active in organization") + } + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 { + if err != nil { + return err + } + return errors.New("authsqlite: grantor is not active in organization") + } + scopeQuery, arguments := `SELECT 1`, []any{} + switch { + case binding.Scope.ServiceID != "": + scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_application_services WHERE id=? AND environment_id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.ServiceID, binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID} + case binding.Scope.EnvironmentID != "": + scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID} + case binding.Scope.ProjectID != "": + scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_projects WHERE id=? AND organization_id=?`, []any{binding.Scope.ProjectID, binding.Scope.OrganizationID} + } + if err = tx.QueryRowContext(ctx, scopeQuery, arguments...).Scan(&exists); err != nil { + return err + } + if exists != 1 { + return errors.New("authsqlite: access scope does not exist") + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) VALUES(?,?,?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,?)`, binding.ID, binding.Scope.OrganizationID, binding.SubjectKind, binding.SubjectID, binding.Role, binding.Scope.ProjectID, binding.Scope.EnvironmentID, binding.Scope.ServiceID, binding.GrantedBy, binding.GrantedAt.Unix()); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) Revoke(ctx context.Context, bindingID, actorUserID string, when time.Time) error { + if !opaqueID(bindingID) || !opaqueID(actorUserID) || when.IsZero() { + return errors.New("authsqlite: invalid access revocation") + } + result, err := store.db.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE id=? AND revoked_at IS NULL`, actorUserID, when.Unix(), bindingID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return errors.New("authsqlite: access binding not found") + } + return nil +} + +func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userID string) ([]access.Binding, error) { + if !opaqueID(organizationID) || !opaqueID(userID) { + return nil, errors.New("authsqlite: invalid access query") + } + rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at + FROM gwf_access_bindings b + WHERE b.organization_id=? AND b.revoked_at IS NULL + AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active') + AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id))) + ORDER BY b.id`, organizationID, userID, userID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []access.Binding + for rows.Next() { + var binding access.Binding + var project, environment, service sql.NullString + var granted int64 + if err = rows.Scan(&binding.ID, &binding.SubjectKind, &binding.SubjectID, &binding.Role, &project, &environment, &service, &binding.GrantedBy, &granted); err != nil { + return nil, err + } + binding.Scope = access.Scope{OrganizationID: organizationID, ProjectID: project.String, EnvironmentID: environment.String, ServiceID: service.String} + binding.GrantedAt = time.Unix(granted, 0).UTC() + result = append(result, binding) + } + return result, rows.Err() +} + +func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error { + if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID { + return errors.New("authsqlite: invalid break-glass event") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil { + return err + } + if err = appendAccessAudit(ctx, tx, audit); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID string, now time.Time) ([]access.BreakGlass, error) { + if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() { + return nil, errors.New("authsqlite: invalid break-glass query") + } + rows, err := store.db.QueryContext(ctx, `SELECT id,permission_name,reason,created_at,expires_at FROM gwf_break_glass WHERE organization_id=? AND user_id=? AND expires_at>? ORDER BY expires_at`, organizationID, userID, now.Unix()) + if err != nil { + return nil, err + } + defer rows.Close() + var result []access.BreakGlass + for rows.Next() { + var grant access.BreakGlass + var created, expires int64 + if err = rows.Scan(&grant.ID, &grant.Permission, &grant.Reason, &created, &expires); err != nil { + return nil, err + } + grant.OrganizationID, grant.UserID = organizationID, userID + grant.CreatedAt, grant.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC() + result = append(result, grant) + } + return result, rows.Err() +} + +func (store *Store) AppendAccessAudit(ctx context.Context, audit access.AuditEvent) error { + if !validAccessAudit(audit) { + return errors.New("authsqlite: invalid access audit") + } + _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix()) + return err +} + +func (store *Store) AccessAudit(ctx context.Context, organizationID string, limit int) ([]access.AuditEvent, error) { + if !opaqueID(organizationID) || limit < 1 || limit > 1000 { + return nil, errors.New("authsqlite: invalid access audit query") + } + rows, err := store.db.QueryContext(ctx, `SELECT id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at FROM gwf_access_audit_events WHERE organization_id=? ORDER BY created_at DESC,id DESC LIMIT ?`, organizationID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var result []access.AuditEvent + for rows.Next() { + var event access.AuditEvent + var requestID sql.NullString + var created int64 + if err = rows.Scan(&event.ID, &event.ActorUserID, &event.Action, &event.ResourceType, &event.ResourceID, &requestID, &event.Summary, &created); err != nil { + return nil, err + } + event.OrganizationID = organizationID + event.RequestID = requestID.String + event.CreatedAt = time.Unix(created, 0).UTC() + result = append(result, event) + } + return result, rows.Err() +} + +func appendAccessAudit(ctx context.Context, tx *sql.Tx, audit access.AuditEvent) error { + _, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix()) + return err +} + +func validBreakGlass(grant access.BreakGlass) bool { + return opaqueID(grant.ID) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && safeName(grant.Permission) && text(grant.Reason, 1024, false) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) <= time.Hour +} + +func validAccessAudit(audit access.AuditEvent) bool { + return opaqueID(audit.ID) && opaqueID(audit.OrganizationID) && opaqueID(audit.ActorUserID) && safeName(audit.Action) && safeName(audit.ResourceType) && text(audit.ResourceID, 256, false) && text(audit.RequestID, 128, true) && text(audit.Summary, 1024, true) && !audit.CreatedAt.IsZero() +} diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go new file mode 100644 index 0000000..dbebf8b --- /dev/null +++ b/authsqlite/organizations.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "database/sql" + "errors" + "time" + + "gamertan.com/web/organizations" +) + +func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) error { + if !opaqueID(organization.ID) || !slugValue(organization.Slug) || !text(organization.Name, 128, false) || organization.CreatedAt.IsZero() || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() { + return errors.New("authsqlite: invalid organization") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var personalOwner any + if organization.Personal { + personalOwner = owner.UserID + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at) VALUES(?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix()); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil { + return err + } + return tx.Commit() +} + +func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error { + if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() { + return errors.New("authsqlite: invalid team") + } + _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix()) + return err +} + +func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership) error { + if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() { + return errors.New("authsqlite: invalid team membership") + } + result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at) + SELECT t.id,?,? FROM gwf_teams t + JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active' + WHERE t.id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrMembershipNotFound + } + return nil +} + +func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error { + if !opaqueID(project.ID) || !opaqueID(project.OrganizationID) || !slugValue(project.Slug) || !text(project.Name, 128, false) || project.CreatedAt.IsZero() { + return errors.New("authsqlite: invalid project") + } + _, err := store.db.ExecContext(ctx, `INSERT INTO gwf_projects(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, project.ID, project.OrganizationID, project.Slug, project.Name, project.CreatedAt.Unix()) + return err +} + +func (store *Store) CreateEnvironment(ctx context.Context, environment organizations.Environment) error { + if !opaqueID(environment.ID) || !opaqueID(environment.OrganizationID) || !opaqueID(environment.ProjectID) || !slugValue(environment.Slug) || !text(environment.Name, 128, false) || environment.CreatedAt.IsZero() { + return errors.New("authsqlite: invalid environment") + } + result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_environments(id,organization_id,project_id,slug,name,created_at) + SELECT ?,?,?,?,?,? FROM gwf_projects WHERE id=? AND organization_id=?`, environment.ID, environment.OrganizationID, environment.ProjectID, environment.Slug, environment.Name, environment.CreatedAt.Unix(), environment.ProjectID, environment.OrganizationID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return errors.New("authsqlite: project is outside organization") + } + return nil +} + +func (store *Store) CreateApplicationService(ctx context.Context, application organizations.ApplicationService) error { + if !opaqueID(application.ID) || !opaqueID(application.OrganizationID) || !opaqueID(application.ProjectID) || !opaqueID(application.EnvironmentID) || !slugValue(application.Slug) || !text(application.Name, 128, false) || application.CreatedAt.IsZero() { + return errors.New("authsqlite: invalid application service") + } + result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_application_services(id,organization_id,project_id,environment_id,slug,name,created_at) + SELECT ?,?,?,?,?,?,? FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, application.ID, application.OrganizationID, application.ProjectID, application.EnvironmentID, application.Slug, application.Name, application.CreatedAt.Unix(), application.EnvironmentID, application.ProjectID, application.OrganizationID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return errors.New("authsqlite: environment is outside project") + } + return nil +} + +func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) error { + if zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() { + return errors.New("authsqlite: invalid invitation") + } + result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at) + SELECT ?,?,?,?,?,? FROM gwf_organization_memberships + WHERE organization_id=? AND user_id=? AND status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.OrganizationID, invitation.InvitedByUserID) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrMembershipNotFound + } + return nil +} + +func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) { + if zeroDigest(digest) || now.IsZero() { + return organizations.Invitation{}, organizations.ErrInvitationNotFound + } + var invitation organizations.Invitation + var created, expires int64 + err := store.db.QueryRowContext(ctx, `SELECT organization_id,email_normalized,invited_by_user_id,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &created, &expires) + if errors.Is(err, sql.ErrNoRows) { + return organizations.Invitation{}, organizations.ErrInvitationNotFound + } + if err != nil { + return organizations.Invitation{}, err + } + invitation.Digest = digest + invitation.CreatedAt = time.Unix(created, 0).UTC() + invitation.ExpiresAt = time.Unix(expires, 0).UTC() + return invitation, nil +} + +func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time) error { + if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() { + return organizations.ErrInvitationNotFound + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var organizationID string + err = tx.QueryRowContext(ctx, `SELECT i.organization_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized WHERE i.token_hash=? AND i.used_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&organizationID) + if errors.Is(err, sql.ErrNoRows) { + return organizations.ErrInvitationNotFound + } + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:]) + if err != nil { + return err + } + if changed, _ := result.RowsAffected(); changed != 1 { + return organizations.ErrInvitationNotFound + } + return tx.Commit() +} + +func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]organizations.Membership, error) { + if !opaqueID(userID) { + return nil, errors.New("authsqlite: invalid user") + } + rows, err := store.db.QueryContext(ctx, `SELECT organization_id,status,joined_at FROM gwf_organization_memberships WHERE user_id=? ORDER BY organization_id`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []organizations.Membership + for rows.Next() { + var membership organizations.Membership + var joined int64 + if err = rows.Scan(&membership.OrganizationID, &membership.Status, &joined); err != nil { + return nil, err + } + membership.UserID = userID + membership.JoinedAt = time.Unix(joined, 0).UTC() + result = append(result, membership) + } + return result, rows.Err() +} + +func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) { + if !opaqueID(organizationID) || !opaqueID(userID) { + return nil, errors.New("authsqlite: invalid team query") + } + rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.created_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id WHERE t.organization_id=? AND tm.user_id=? ORDER BY t.slug`, organizationID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var result []organizations.Team + for rows.Next() { + var team organizations.Team + var created int64 + if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil { + return nil, err + } + team.OrganizationID = organizationID + team.CreatedAt = time.Unix(created, 0).UTC() + result = append(result, team) + } + return result, rows.Err() +} + +func slugValue(value string) bool { + if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') { + return false + } + for _, character := range value { + if character != '-' && (character < 'a' || character > 'z') && (character < '0' || character > '9') { + return false + } + } + return true +} diff --git a/authsqlite/store.go b/authsqlite/store.go index 67f354d..0b62829 100644 --- a/authsqlite/store.go +++ b/authsqlite/store.go @@ -88,6 +88,26 @@ func (store *Store) Migrate(ctx context.Context) error { `CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`, `CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`, `CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`, + `CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`, + `CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`, + `CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`, + `CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`, + `CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`, + `CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`, + `CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`, + `CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`, + `CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`, + `CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`, + `CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, + `CREATE TABLE IF NOT EXISTS gwf_access_role_permissions (role_name TEXT NOT NULL REFERENCES gwf_access_roles(name) ON DELETE CASCADE, permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name) ON DELETE CASCADE, PRIMARY KEY(role_name,permission_name))`, + `CREATE TABLE IF NOT EXISTS gwf_access_bindings (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, subject_kind TEXT NOT NULL CHECK(subject_kind IN ('user','team')), subject_id TEXT NOT NULL, role_name TEXT NOT NULL REFERENCES gwf_access_roles(name), project_id TEXT, environment_id TEXT, service_id TEXT, granted_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), granted_at INTEGER NOT NULL, revoked_by_user_id TEXT REFERENCES gwf_users(id), revoked_at INTEGER)`, + `CREATE INDEX IF NOT EXISTS gwf_access_bindings_scope ON gwf_access_bindings(organization_id,subject_kind,subject_id,revoked_at)`, + `CREATE TABLE IF NOT EXISTS gwf_break_glass (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id), permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name), reason TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS gwf_break_glass_active ON gwf_break_glass(organization_id,user_id,expires_at)`, + `CREATE TABLE IF NOT EXISTS gwf_access_audit_events (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, actor_user_id TEXT NOT NULL REFERENCES gwf_users(id), action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`, + `CREATE INDEX IF NOT EXISTS gwf_access_audit_created ON gwf_access_audit_events(organization_id,created_at)`, } for _, statement := range statements { if _, err = tx.ExecContext(ctx, statement); err != nil { @@ -97,6 +117,9 @@ func (store *Store) Migrate(ctx context.Context) error { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil { return err } + if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(2,?)`, time.Now().UTC().Unix()); err != nil { + return err + } return tx.Commit() } diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index d206f5e..9e25c73 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -10,7 +10,9 @@ import ( "testing" "time" + "gamertan.com/web/access" "gamertan.com/web/auth" + "gamertan.com/web/organizations" ) func TestServiceRoundTripWithApplicationPolicy(t *testing.T) { @@ -115,3 +117,93 @@ func TestOpenRejectsSymlinkDatabase(t *testing.T) { t.Fatal("symlink database accepted") } } + +func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) { + store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + now := time.Unix(2000, 0).UTC() + authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "owner.one", Email: "owner@example.test", DisplayName: "Owner", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.one", Email: "member@example.test", DisplayName: "Member", Password: "correct horse battery staple"}) + if err != nil { + t.Fatal(err) + } + organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "observatory-test", Name: "Observatory Test", OwnerUserID: owner.ID}) + if err != nil { + t.Fatal(err) + } + raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour) + if err != nil { + t.Fatal(err) + } + if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil { + t.Fatal(err) + } + team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators"}) + if err != nil { + t.Fatal(err) + } + if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID); err != nil { + t.Fatal(err) + } + project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"}) + if err != nil { + t.Fatal(err) + } + environment, err := organizationService.CreateEnvironment(t.Context(), organizations.CreateEnvironment{OrganizationID: organization.ID, ProjectID: project.ID, Slug: "production", Name: "Production"}) + if err != nil { + t.Fatal(err) + } + application, err := organizationService.CreateApplicationService(t.Context(), organizations.CreateApplicationService{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, Slug: "web", Name: "Web"}) + if err != nil { + t.Fatal(err) + } + policy := access.Policy{Roles: map[string]string{"viewer": "Read telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}} + accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + scope := access.Scope{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, ServiceID: application.ID} + if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.Team, SubjectID: team.ID, Role: "viewer", Scope: scope, GrantedBy: owner.ID}); err != nil { + t.Fatal(err) + } + decision, err := accessService.Authorize(t.Context(), member.ID, scope, "telemetry.read") + if err != nil || !decision.Allowed || decision.Source != "role" { + t.Fatalf("decision=%+v err=%v", decision, err) + } + decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read") + if err != nil || decision.Allowed { + t.Fatalf("sensitive decision=%+v err=%v", decision, err) + } + if _, err = accessService.ActivateBreakGlass(t.Context(), organization.ID, member.ID, "telemetry.sensitive.read", "Investigate the active production incident", "request-12345678", 15*time.Minute); err != nil { + t.Fatal(err) + } + decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read") + if err != nil || !decision.Allowed || decision.Source != "break_glass" { + t.Fatalf("break-glass decision=%+v err=%v", decision, err) + } + var audits int + if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 1 { + t.Fatalf("audits=%d err=%v", audits, err) + } + auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10) + if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "break_glass.activate" { + t.Fatalf("audit events=%+v err=%v", auditEvents, err) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3c82a85..85106cb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -9,6 +9,8 @@ analytics ──> requestlog ──> requestmeta abuse ─────────────────────> requestmeta authhttp ──> websec ───────> requestmeta authhttp ──> auth <───────── authsqlite +organizations <───────────── authsqlite +access <──────────────────── authsqlite ``` An ordinary `net/http` application composes whichever branches it needs. @@ -19,6 +21,12 @@ Storage and reporting surfaces are interfaces so an application can retain its existing database and user interface while replacing one implementation at a time. +Authentication establishes one user identity and session. Organizations own +projects, environments, and services; teams group organization members; scoped +access resolves roles against that hierarchy. Existing `auth` roles remain a +platform-level compatibility surface and do not implicitly grant access to an +organization's data. Emergency access is a separate, expiring, audited grant. + The package model is developed from explicit threat and data contracts, not by moving an existing application's internals into a shared directory. See [ADOPTION.md](ADOPTION.md), [GETTING_STARTED.md](GETTING_STARTED.md), and the diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 48cb957..c963ee1 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -17,13 +17,15 @@ install an imagined framework lifecycle around it. | Users, credentials, permissions, and sessions | `auth` | Roles, permissions, login UX, and account policy | | Secure browser cookies around `auth` | `authhttp` | Login routes, redirects, pages, and authorization decisions | | SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery | +| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy | +| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement | | Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention | The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.1 +go get gamertan.com/web/requestmeta@v0.1.0-preview.2 go mod verify ``` @@ -78,3 +80,7 @@ Deeper tutorials for accounts, analytics, and persistent abuse policy will be written after multiple application migrations have validated those seams. The preview documentation describes demonstrated contracts rather than prescribing an unfinished application framework. + +See [Organizations and scoped access](ORGANIZATIONS.md) before storing tenant +data. In particular, do not interpret a platform role as permission to inspect +an organization's records. diff --git a/docs/MODULES.md b/docs/MODULES.md index 8648bfc..56792b9 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.1 +go get gamertan.com/web/requestmeta@v0.1.0-preview.2 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md new file mode 100644 index 0000000..91552c0 --- /dev/null +++ b/docs/ORGANIZATIONS.md @@ -0,0 +1,41 @@ + + +# Organizations and scoped access + +One `auth.User` may belong to many organizations without creating another +credential or browser session. Organizations own projects; projects own +environments; environments own application services. Teams are optional groups +of active organization members. + +`organizations.Service` creates those resources and issues digest-backed, +expiring, single-use invitations. Acceptance verifies that the authenticated +user's normalized email matches the invitation before activating membership. +Applications own invitation pages, email or out-of-band delivery, organization +deletion policy, and account recovery. + +`access.Service` evaluates a permission against a complete resource scope: + +```go +decision, err := accessService.Authorize(ctx, principal.User.ID, access.Scope{ + OrganizationID: organizationID, + ProjectID: projectID, + EnvironmentID: environmentID, + ServiceID: serviceID, +}, "telemetry.read") +``` + +A binding at organization scope covers its descendants. A narrower binding +covers only its matching branch. The repository resolves team membership; a +handler must never accept caller-supplied team identifiers as authority. + +Platform roles in `auth.Principal` remain useful for installation health, +account administration, and other explicitly global operations. They do not +grant organization-data access. If an operator must inspect tenant data during +an incident, use a reasoned break-glass grant. It expires within one hour and +creates an append-only audit event in the same transaction. + +The SQLite adapter namespaces all tables, enforces organization membership and +resource ancestry before accepting a binding, and keeps invitations and +sessions as digests. Applications remain responsible for database backup, +filesystem ownership, retention, and presenting audit history to organization +owners. diff --git a/docs/THREAT_MODEL.md b/docs/THREAT_MODEL.md index 48a27b6..7368620 100644 --- a/docs/THREAT_MODEL.md +++ b/docs/THREAT_MODEL.md @@ -10,7 +10,9 @@ selected storage adapters are trusted. Controls include explicit proxy trust, bounded parsing, cryptographic request and session identifiers, digest-only session storage, Argon2id passwords, constant-time comparisons, same-origin and CSRF primitives, fail-closed storage -errors, and separate safe/sensitive analytics projections. +errors, separate safe/sensitive analytics projections, organization-scoped +bindings, single-use invitation digests, and short-lived audited break-glass +grants. Unsafe methods without an exact Origin or trustworthy same-origin Fetch Metadata fail the origin check. Authentication middleware fails closed when its @@ -22,6 +24,12 @@ configured reverse proxy, authorize application routes automatically, encrypt a compromised host, or decide how long an operator may lawfully retain personal request evidence. +Applications must pass the authenticated user and requested resource hierarchy +to `access.Authorize`; possessing a platform-level `auth` role does not bypass +that decision. Team membership is resolved by the repository rather than +accepted from request input. Break-glass access lasts at most one hour and is +not a substitute for ordinary role policy. + Local storage adapters assume the parent directory and host account are trusted. They reject a symlink at the configured final path and apply private file modes, but they do not defend against a concurrent privileged actor replacing path diff --git a/organizations/organizations.go b/organizations/organizations.go new file mode 100644 index 0000000..44127ea --- /dev/null +++ b/organizations/organizations.go @@ -0,0 +1,281 @@ +// SPDX-License-Identifier: MPL-2.0 + +// Package organizations defines storage-neutral organizations, teams, +// memberships, and single-use invitations. +package organizations + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "regexp" + "strings" + "time" +) + +var ( + ErrInvitationNotFound = errors.New("organizations: invitation not found") + ErrMembershipNotFound = errors.New("organizations: membership not found") + slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`) + idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) +) + +type Organization struct { + ID string + Slug string + Name string + Personal bool + CreatedAt time.Time +} + +type Membership struct { + OrganizationID string + UserID string + Status string + JoinedAt time.Time +} + +type Team struct { + ID, OrganizationID, Slug, Name string + CreatedAt time.Time +} + +type TeamMembership struct { + TeamID, UserID string + JoinedAt time.Time +} + +type Project struct { + ID, OrganizationID, Slug, Name string + CreatedAt time.Time +} + +type Environment struct { + ID, OrganizationID, ProjectID, Slug, Name string + CreatedAt time.Time +} + +type ApplicationService struct { + ID, OrganizationID, ProjectID, EnvironmentID, Slug, Name string + CreatedAt time.Time +} + +type Invitation struct { + Digest [32]byte + OrganizationID string + Email, InvitedByUserID string + CreatedAt, ExpiresAt, UsedAt time.Time +} + +type Repository interface { + CreateOrganization(context.Context, Organization, Membership) error + CreateTeam(context.Context, Team) error + AddTeamMember(context.Context, TeamMembership) error + CreateProject(context.Context, Project) error + CreateEnvironment(context.Context, Environment) error + CreateApplicationService(context.Context, ApplicationService) error + CreateInvitation(context.Context, Invitation) error + InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error) + AcceptInvitation(context.Context, [32]byte, string, time.Time) error + MembershipsForUser(context.Context, string) ([]Membership, error) + TeamsForUser(context.Context, string, string) ([]Team, error) +} + +type Options struct { + Random io.Reader + Now func() time.Time +} + +type Service struct { + repository Repository + random io.Reader + now func() time.Time +} + +func New(repository Repository, options Options) (*Service, error) { + if repository == nil { + return nil, errors.New("organizations: repository is required") + } + if options.Random == nil { + options.Random = rand.Reader + } + if options.Now == nil { + options.Now = time.Now + } + return &Service{repository: repository, random: options.Random, now: options.Now}, nil +} + +type CreateOrganization struct { + Slug, Name, OwnerUserID string + Personal bool +} + +func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) { + input.Slug = strings.ToLower(strings.TrimSpace(input.Slug)) + input.Name = strings.TrimSpace(input.Name) + if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) { + return Organization{}, errors.New("organizations: invalid organization") + } + id, err := token(service.random, 18) + if err != nil { + return Organization{}, err + } + now := service.now().UTC() + organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now} + owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now} + if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil { + return Organization{}, err + } + return organization, nil +} + +func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) { + value := make([]byte, 6) + if _, err := io.ReadFull(service.random, value); err != nil { + return Organization{}, fmt.Errorf("organizations: secure randomness unavailable: %w", err) + } + suffix := hex.EncodeToString(value) + return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true}) +} + +type CreateTeam struct{ OrganizationID, Slug, Name string } + +func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) { + input.Slug = strings.ToLower(strings.TrimSpace(input.Slug)) + input.Name = strings.TrimSpace(input.Name) + if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) { + return Team{}, errors.New("organizations: invalid team") + } + id, err := token(service.random, 18) + if err != nil { + return Team{}, err + } + team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()} + if err = service.repository.CreateTeam(ctx, team); err != nil { + return Team{}, err + } + return team, nil +} + +func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error { + if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) { + return errors.New("organizations: invalid team membership") + } + return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()}) +} + +type CreateProject struct{ OrganizationID, Slug, Name string } + +func (service *Service) CreateProject(ctx context.Context, input CreateProject) (Project, error) { + input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name) + if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) { + return Project{}, errors.New("organizations: invalid project") + } + id, err := token(service.random, 18) + if err != nil { + return Project{}, err + } + project := Project{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()} + if err = service.repository.CreateProject(ctx, project); err != nil { + return Project{}, err + } + return project, nil +} + +type CreateEnvironment struct{ OrganizationID, ProjectID, Slug, Name string } + +func (service *Service) CreateEnvironment(ctx context.Context, input CreateEnvironment) (Environment, error) { + input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name) + if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) { + return Environment{}, errors.New("organizations: invalid environment") + } + id, err := token(service.random, 18) + if err != nil { + return Environment{}, err + } + environment := Environment{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()} + if err = service.repository.CreateEnvironment(ctx, environment); err != nil { + return Environment{}, err + } + return environment, nil +} + +type CreateApplicationService struct{ OrganizationID, ProjectID, EnvironmentID, Slug, Name string } + +func (service *Service) CreateApplicationService(ctx context.Context, input CreateApplicationService) (ApplicationService, error) { + input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name) + if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !idPattern.MatchString(input.EnvironmentID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) { + return ApplicationService{}, errors.New("organizations: invalid application service") + } + id, err := token(service.random, 18) + if err != nil { + return ApplicationService{}, err + } + application := ApplicationService{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()} + if err = service.repository.CreateApplicationService(ctx, application); err != nil { + return ApplicationService{}, err + } + return application, nil +} + +func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) { + email = strings.ToLower(strings.TrimSpace(email)) + if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { + return "", Invitation{}, errors.New("organizations: invalid invitation") + } + raw, err := token(service.random, 32) + if err != nil { + return "", Invitation{}, err + } + now := service.now().UTC() + invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, CreatedAt: now, ExpiresAt: now.Add(lifetime)} + if err = service.repository.CreateInvitation(ctx, invitation); err != nil { + return "", Invitation{}, err + } + return raw, invitation, nil +} + +func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID string) error { + if len(rawToken) < 32 || len(rawToken) > 128 || !idPattern.MatchString(userID) { + return ErrInvitationNotFound + } + digest := sha256.Sum256([]byte(rawToken)) + now := service.now().UTC() + if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil { + return err + } + return service.repository.AcceptInvitation(ctx, digest, userID, now) +} + +func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) { + if !idPattern.MatchString(userID) { + return nil, errors.New("organizations: invalid user") + } + return service.repository.MembershipsForUser(ctx, userID) +} + +func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) { + if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) { + return nil, errors.New("organizations: invalid team query") + } + return service.repository.TeamsForUser(ctx, organizationID, userID) +} + +func (service *Service) Repository() Repository { return service.repository } + +func token(random io.Reader, size int) (string, error) { + value := make([]byte, size) + if _, err := io.ReadFull(random, value); err != nil { + return "", fmt.Errorf("organizations: secure randomness unavailable: %w", err) + } + return base64.RawURLEncoding.EncodeToString(value), nil +} + +func bounded(value string, limit int) bool { + return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go new file mode 100644 index 0000000..66a7ea1 --- /dev/null +++ b/organizations/organizations_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MPL-2.0 + +package organizations + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestCreateInviteAndAccept(t *testing.T) { + now := time.Unix(1000, 0).UTC() + repository := &repositoryStub{} + service, err := New(repository, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + organization, err := service.CreateOrganization(t.Context(), CreateOrganization{Slug: "quiet-systems", Name: "Quiet Systems", OwnerUserID: "user-12345678"}) + if err != nil || organization.ID == "" || repository.organization.ID != organization.ID { + t.Fatalf("organization=%+v err=%v", organization, err) + } + raw, invitation, err := service.Invite(t.Context(), organization.ID, "MEMBER@example.test", "user-12345678", time.Hour) + if err != nil || raw == "" || invitation.Email != "member@example.test" { + t.Fatalf("invitation=%+v err=%v", invitation, err) + } + repository.invitation = invitation + if err = service.AcceptInvitation(t.Context(), raw, "user-87654321"); err != nil { + t.Fatal(err) + } + if repository.acceptedUser != "user-87654321" { + t.Fatalf("accepted=%q", repository.acceptedUser) + } +} + +func TestInvitationFailsClosed(t *testing.T) { + service, err := New(&repositoryStub{invitationErr: ErrInvitationNotFound}, Options{Random: strings.NewReader(strings.Repeat("x", 256))}) + if err != nil { + t.Fatal(err) + } + if err = service.AcceptInvitation(t.Context(), strings.Repeat("x", 43), "user-87654321"); !errors.Is(err, ErrInvitationNotFound) { + t.Fatalf("err=%v", err) + } + if _, _, err = service.Invite(t.Context(), "bad", "person@example.test", "user-12345678", time.Hour); err == nil { + t.Fatal("invalid organization accepted") + } +} + +type repositoryStub struct { + organization Organization + invitation Invitation + invitationErr error + acceptedUser string +} + +func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error { + repository.organization = organization + return nil +} +func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil } +func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil } +func (*repositoryStub) CreateProject(context.Context, Project) error { return nil } +func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil } +func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error { + return nil +} +func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error { + repository.invitation = invitation + return nil +} +func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error) { + if repository.invitationErr != nil { + return Invitation{}, repository.invitationErr + } + return repository.invitation, nil +} +func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time) error { + repository.acceptedUser = userID + return nil +} +func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) { + return nil, nil +} +func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil } diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index c0d4b12..59a6376 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -14,6 +14,8 @@ README.md SECURITY.md abuse/abuse.go abuse/abuse_test.go +access/access.go +access/access_test.go analytics/analytics.go analytics/analytics_test.go analytics/fuzz_test.go @@ -27,11 +29,14 @@ authhttp/authhttp.go authhttp/authhttp_test.go authsqlite/store.go authsqlite/store_test.go +authsqlite/access.go +authsqlite/organizations.go docs/ADOPTION.md docs/ARCHITECTURE.md docs/DEPENDENCIES.md docs/GETTING_STARTED.md docs/MODULES.md +docs/ORGANIZATIONS.md docs/PUBLIC_SNAPSHOT.md docs/SANDWICH_HIME.md docs/SERVICES_ROADMAP.md @@ -44,6 +49,8 @@ requestlog/requestlog_test.go requestmeta/fuzz_test.go requestmeta/requestmeta.go requestmeta/requestmeta_test.go +organizations/organizations.go +organizations/organizations_test.go scripts/check-licenses.sh scripts/export-public.sh scripts/public-snapshot.allow