release: publish Web Foundations Preview 2 snapshot
Sanitized allowlisted snapshot of private source 0acd276fb3423405daf7fff26dedc92b8281e2bd. Adds organization, team, invitation, resource hierarchy, scoped access, and audited break-glass foundations while preserving Preview 1. AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release preparation. Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<!-- SPDX-License-Identifier: MPL-2.0 -->
|
||||
|
||||
# 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user