release: publish Web Foundations Preview 2 snapshot
verify / verify (push) Successful in 3m2s

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:
2026-08-17 00:26:22 -04:00
parent 206d09e4cd
commit 920e68f57f
16 changed files with 1363 additions and 8 deletions
+10
View File
@@ -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,
+8 -5
View File
@@ -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.
+270
View File
@@ -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")
}
+80
View File
@@ -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
}
+221
View File
@@ -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()
}
+220
View File
@@ -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
}
+23
View File
@@ -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()
}
+92
View File
@@ -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)
}
}
+8
View File
@@ -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
+7 -1
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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.
+9 -1
View File
@@ -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
+281
View File
@@ -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")
}
+85
View File
@@ -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 }
+7
View File
@@ -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