Author SHA1 Message Date
gamertan 5905fe6fb2 auth: publish one-time bootstrap rotation
verify / verify (push) Successful in 3m14s
Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation.

Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 00:08:01 -04:00
gamertan 920e68f57f 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>
2026-08-17 00:26:22 -04:00
gamertan 206d09e4cd docs: publish the Web Foundations application onramp
verify / verify (push) Successful in 2m58s
Sanitized snapshot of private source 144ca0a9544042b0477ae732c1356cf0b9d62b3f. Add package selection, adoption workflow, and optional Sandwich Hime integration guidance.

AI-Assistance: OpenAI Codex assisted documentation, verification, and publication.
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 21:10:18 -04:00
25 changed files with 1920 additions and 25 deletions
+22
View File
@@ -2,6 +2,28 @@
# Changelog
## v0.1.0-preview.3 — 2026-08-18
- Add cryptographically generated temporary credentials and an explicit
password-change-required account state.
- Replace credentials, clear the requirement, and revoke all existing sessions
in one repository transaction after verifying the current password.
- Migrate existing SQLite users with the new requirement disabled; applications
continue to own first-login routing, private credential delivery, and audit
policy.
- Keep Preview 1 and Preview 2 immutable; applications select Preview 3
explicitly when adopting forced bootstrap rotation.
## 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,
+39 -9
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,10 +23,20 @@ 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.2
```
The version belongs to the `gamertan.com/web` module. Go compiles and links
only the packages the application imports. See the [getting-started guide](docs/GETTING_STARTED.md)
and [module-boundary policy](docs/MODULES.md) before choosing a first slice.
Canonical source, issues, security policy, and release notes live on
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin.
@@ -38,17 +48,37 @@ without turning that portability into a maintained compatibility claim.
## Packages
- `requestmeta`: trusted-proxy resolution, HTTPS/origin metadata, and request IDs.
- `requestlog`: bounded versioned records, middleware, sinks, and private JSONL.
- `websec`: headers, origin checks, CSRF, redirects, body limits, and rate limits.
- `abuse`: application-classified request abuse with pluggable persistence.
- `auth`, `authhttp`, `authsqlite`: passwords, sessions, permissions, cookies,
and a no-CGO SQLite adapter.
- `analytics`: safe and sensitive aggregate projections over request records.
- [`requestmeta`](requestmeta): trusted-proxy resolution, HTTPS/origin metadata,
and request IDs.
- [`requestlog`](requestlog): bounded versioned records, middleware, sinks, and
private JSONL.
- [`websec`](websec): headers, origin checks, CSRF, redirects, body limits, and
rate limits.
- [`abuse`](abuse): application-classified request abuse with pluggable persistence.
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
passwords, forced first-login rotation, session revocation, 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.
The copyable starter under `starters/basic` demonstrates the packages without
turning them into a router or template system.
## HTML and templates
Web Foundations deliberately does not provide a template language. Sandwich
Hime is the preferred companion for Gamertan applications that want HTML-first,
typed, ahead-of-time Go templates. The two projects remain independently
usable: this module does not import the `sando` runtime, and Sandwich Hime does
not own middleware, authentication, logging, routing, or deployment.
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md), then follow the official
[first site tutorial](https://sandwichhime.com/docs/tutorial/) and
[application integration tutorial](https://sandwichhime.com/docs/tutorial/application/).
## Security boundary
Client addresses are accepted from forwarding headers only when the immediate
+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
}
+53 -2
View File
@@ -21,6 +21,7 @@ import (
var (
ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active")
ErrPasswordUnchanged = errors.New("auth: new password must differ from the current password")
ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
@@ -29,6 +30,7 @@ var (
type User struct {
ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
}
type Principal struct {
@@ -59,6 +61,8 @@ type PolicySeed struct {
type Repository interface {
CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error)
CredentialByUserID(context.Context, string) (User, string, error)
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
@@ -102,7 +106,10 @@ func New(repository Repository, options Options) (*Service, error) {
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
}
type CreateUser struct{ Username, Email, DisplayName, Password string }
type CreateUser struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username)
@@ -120,13 +127,57 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User,
return User{}, err
}
now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now, PasswordChangeRequired: input.RequirePasswordChange}
if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err
}
return user, nil
}
// GenerateTemporaryPassword returns 256 bits of URL-safe cryptographic
// entropy suitable for an application-managed one-time bootstrap credential.
func GenerateTemporaryPassword(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
return randomToken(random, 32)
}
// ChangePassword verifies the current credential, rejects reuse, replaces the
// Argon2id hash, clears the password-change requirement, and revokes every
// existing session through one repository operation.
func (service *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, currentHash, err := service.repository.CredentialByUserID(ctx, strings.TrimSpace(userID))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
return ErrInactiveUser
}
if currentPassword == newPassword {
return ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(newPassword, service.random)
if err != nil {
return err
}
if err = service.repository.ReplacePasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, service.now().UTC()); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("auth: replace password: %w", err)
}
return nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")
+13
View File
@@ -28,6 +28,19 @@ func TestPasswordEntropyFailsClosed(t *testing.T) {
}
}
func TestTemporaryPasswordUsesBoundedCryptographicEntropy(t *testing.T) {
password, err := GenerateTemporaryPassword(strings.NewReader(strings.Repeat("t", 32)))
if err != nil {
t.Fatal(err)
}
if len(password) != 43 || ValidatePassword(password) != nil || strings.ContainsAny(password, " \t\r\n") {
t.Fatalf("temporary password length=%d", len(password))
}
if _, err = GenerateTemporaryPassword(errorReader{}); err == nil {
t.Fatal("temporary password accepted entropy failure")
}
}
type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
+6
View File
@@ -59,6 +59,12 @@ func (repositoryStub) CreateUser(context.Context, User, string) error { return n
func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) CredentialByUserID(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
+6
View File
@@ -109,6 +109,12 @@ func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error {
func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
+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
}
+112 -4
View File
@@ -77,7 +77,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback()
statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -88,18 +88,73 @@ 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 {
return err
}
}
hasPasswordRequirement, err := sqliteColumnExists(ctx, tx, "gwf_users", "password_change_required")
if err != nil {
return err
}
if !hasPasswordRequirement {
if _, err = tx.ExecContext(ctx, `ALTER TABLE gwf_users ADD COLUMN password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1))`); err != nil {
return err
}
}
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
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(3,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit()
}
func sqliteColumnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var position, notNull, primaryKey int
var name, kind string
var defaultValue sql.NullString
if err = rows.Scan(&position, &name, &kind, &notNull, &defaultValue, &primaryKey); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error {
if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) {
return errors.New("authsqlite: invalid user")
@@ -109,7 +164,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil {
return err
}
@@ -125,18 +180,69 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &created, &updated, &hash)
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
if err != nil {
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth.User, string, error) {
if !opaqueID(userID) {
return auth.User{}, "", auth.ErrUserNotFound
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
if err != nil {
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
if !opaqueID(userID) || when.IsZero() {
return errors.New("authsqlite: invalid login update")
@@ -160,10 +266,12 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal
var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
var passwordChangeRequired int
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
}
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
if err != nil {
return auth.Principal{}, auth.Session{}, err
}
+167
View File
@@ -3,6 +3,8 @@
package authsqlite
import (
"database/sql"
"errors"
"os"
"path/filepath"
"runtime"
@@ -10,7 +12,9 @@ import (
"testing"
"time"
"gamertan.com/web/access"
"gamertan.com/web/auth"
"gamertan.com/web/organizations"
)
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
@@ -53,6 +57,79 @@ func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
}
}
func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(3000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "bootstrap", Email: "bootstrap@example.test", DisplayName: "Bootstrap Operator", Password: "temporary bootstrap credential", RequirePasswordChange: true})
if err != nil || !user.PasswordChangeRequired {
t.Fatalf("user=%+v err=%v", user, err)
}
token, principal, err := service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
if err = service.ChangePassword(t.Context(), user.ID, "wrong current credential", "new permanent credential"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("wrong current credential err=%v", err)
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("failed rotation revoked session: %v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "temporary bootstrap credential"); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("reused credential err=%v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "new permanent credential"); err != nil {
t.Fatal(err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived rotation: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("temporary credential survived rotation: %v", err)
}
_, principal, err = service.Authenticate(t.Context(), user.Username, "new permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("rotated principal=%+v err=%v", principal, err)
}
}
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
database, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = database.Exec(`CREATE TABLE gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`)
if err == nil {
_, err = database.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('existing-user','existing','existing','existing@example.test','existing@example.test','Existing','active',1,1)`)
}
if closeErr := database.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatal(err)
}
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
var required, migrations int
if err = store.db.QueryRow(`SELECT password_change_required FROM gwf_users WHERE id='existing-user'`).Scan(&required); err != nil || required != 0 {
t.Fatalf("required=%d err=%v", required, err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gamertan_web_migrations WHERE version=3`).Scan(&migrations); err != nil || migrations != 1 {
t.Fatalf("migrations=%d err=%v", migrations, err)
}
}
func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
store, err := Open(path)
@@ -115,3 +192,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)
}
}
+17 -7
View File
@@ -2,22 +2,32 @@
# Architecture
The dependency direction is intentionally one-way:
The package dependency direction is intentionally one-way:
```text
net/http application
-> requestmeta
-> requestlog / websec / abuse / authhttp
-> auth and analytics interfaces
-> optional authsqlite and JSONL adapters
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.
Packages never own application routes, templates, authorization policy, cache
policy, or deployment. Middleware communicates through typed request context.
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).
[ADOPTION.md](ADOPTION.md), [GETTING_STARTED.md](GETTING_STARTED.md), and the
[module-boundary policy](MODULES.md).
+7
View File
@@ -13,6 +13,13 @@ Applications that do not import `auth` or `authsqlite` do not link those
implementations into their binaries. Optional GeoIP enrichment is an interface
only; the base toolkit performs no lookup and adds no GeoIP dependency.
All packages currently share one Go module, so these requirements remain
visible in the module graph even when an application imports only
`requestmeta`. Go still avoids compiling or linking unused packages. A future
nested module may isolate a heavyweight adapter such as `authsqlite` when its
independent dependency and release lifecycle justify the additional tags,
vanity metadata, and CI. See [MODULES.md](MODULES.md).
`go.sum`, `go mod verify`, checksum-database verification, vulnerability
scanning, and the public snapshot allowlist are release gates. Binary
distributors remain responsible for preserving all applicable upstream notices.
+102
View File
@@ -0,0 +1,102 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Getting started
Gamertan Web Foundations is adopted one boundary at a time. Start with the
smallest package that solves a problem the application actually has; do not
install an imagined framework lifecycle around it.
## Choose a first slice
| Application need | Begin with | What remains application-owned |
| --- | --- | --- |
| Request IDs and trustworthy client addresses | `requestmeta` | Proxy configuration and operational logs |
| Bounded structured request evidence | `requestmeta`, `requestlog` | Route names, sensitive-field policy, rotation, retention, and access |
| Browser and HTTP safety primitives | `requestmeta`, `websec` | Exact CSP, route authorization, and response policy |
| Persistent request-abuse decisions | `requestmeta`, `abuse` | Route classification, storage, appeals, and operator policy |
| 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.3
go mod verify
```
## Preserve middleware order
Packages that consume request metadata must run inside the resolver. Build the
handler from the application outward; the final resolver assignment becomes
the first middleware to receive a request:
```go
var handler http.Handler = router
handler = requestlog.Middleware(sink, logPolicy)(handler)
handler = websec.Headers(headerPolicy)(handler)
handler = resolver.Middleware(handler)
```
The complete, copyable composition is in [`starters/basic`](../starters/basic).
It binds to loopback, shuts down gracefully, and keeps request logging optional.
Configure trusted proxy networks narrowly. A forwarding header is not evidence
by itself; it becomes usable only when the immediate peer and skipped proxy
hops satisfy the resolver's trust policy. Metadata, authentication, or storage
failures that affect security decisions should stop the request rather than
quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
not write or print the credential because file ownership, operator identity,
and delivery are application policy.
After authentication, inspect `principal.User.PasswordChangeRequired`. Until it
is false, permit only password change and logout. `auth.ChangePassword` verifies
the current credential, rejects reuse, writes the new Argon2id hash, clears the
requirement, and revokes every existing session atomically through the storage
adapter. Clear the browser cookie and require a fresh login after success. Do
not treat a redirect alone as enforcement; apply the restriction before every
protected handler.
## Add HTML without merging responsibilities
Handlers should convert request and service state into typed display data.
They may then render those values with any HTML system. Gamertan's preferred
companion is [Sandwich Hime](SANDWICH_HIME.md), whose generated components keep
templates typed while leaving this middleware stack and the `net/http`
application in control.
## Verify the application boundary
After adopting a package:
```bash
go mod verify
go test ./...
go test -race ./...
go vet ./...
go build ./...
```
Test the composed handler with `httptest`, not only the package in isolation.
Include a normal request, malformed or spoofed metadata, a downstream failure,
and the application's intended response headers. Existing applications should
follow the differential and rollback sequence in [ADOPTION.md](ADOPTION.md).
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.
+72
View File
@@ -0,0 +1,72 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Packages, modules, and repositories
These boundaries solve different problems:
- a **package** owns one Go responsibility and import path;
- a **module** owns dependency selection and semantic versions; and
- a **repository** owns contribution, security, and release operations.
The first preview uses one repository and one module, `gamertan.com/web`, with
several independently importable packages. An application may write:
```go
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.2
```
Only imported packages are compiled and linked. The packages nevertheless
share the module's version and dependency graph.
## Why not one repository per package?
Separate repositories would multiply release credentials, security updates,
vanity-import records, tags, CI, issue tracking, and coordinated API changes.
A focused pull request can already change and test one package directory. A
repository boundary is reserved for software with an independently operated
lifecycle, such as a future standalone `authd` service.
## When a nested module is justified
A package may become a nested module inside this repository when all of these
are true:
1. it introduces materially heavier or different dependencies;
2. consumers can usefully version it independently;
3. its API boundary has survived real application adoption; and
4. separate tags, release ordering, vanity metadata, and CI are less costly
than keeping it in the root module.
`authsqlite` is the clearest current candidate because it carries the optional
SQLite implementation and its transitive module graph. A future split could
retain the import path `gamertan.com/web/authsqlite` while giving that directory
its own `go.mod` and tags such as `authsqlite/v0.1.0-preview.1`.
Do not split merely to make an architecture diagram look modular. Package
interfaces provide source-level modularity today; modules are introduced only
for an independent dependency and release lifecycle.
## Session boundaries
Authenticated sessions currently belong to three deliberate packages:
- `auth` owns opaque token creation, digest-backed session lookup, revocation,
and the storage interface;
- `authhttp` binds those sessions to secure browser cookies and request
context; and
- `authsqlite` persists the storage contract.
A separate `session` package would be appropriate only for a genuinely
identity-neutral need, such as anonymous application sessions with no user,
role, or credential semantics. It should not duplicate `auth` under a more
general name.
This policy may evolve before a stable release. Any split must include a
migration guide and preserve already published versions at their original
module coordinates.
+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.
+71
View File
@@ -0,0 +1,71 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# HTML with Sandwich Hime
Gamertan Web Foundations owns reusable web-application boundaries; it does not
own HTML or a template language. [Sandwich Hime](https://sandwichhime.com/) is
the preferred companion for Gamertan applications that want HTML-first,
ahead-of-time templates with typed Go composition.
The relationship is intentionally optional:
| Application responsibility | Owner |
| --- | --- |
| Request identity, logging, security primitives, sessions, and analytics | Web Foundations packages selected by the application |
| Routing, authorization decisions, status, headers, caching, and deployment | The application |
| Visible HTML and typed component composition | Authored `.sando` templates |
| Template parsing, contextual analysis, and Go generation | Hime-san during development or CI |
| Rendering generated components | The small `sando` runtime in production |
Web Foundations does not import Sandwich Hime. Sandwich Hime does not import
Web Foundations. An application chooses both and provides the seam between
them.
## Request flow
```text
request
-> requestmeta / selected middleware
-> application router and handler
-> typed view data
-> generated Sandwich Hime component
-> buffered sando.Render
-> application-owned HTTP response
```
Buffer the component before committing a successful response so a rendering
error can still become a clean application error:
```go
func renderHTML(response http.ResponseWriter, request *http.Request, status int, component sando.Component) {
var output bytes.Buffer
if err := sando.Render(request.Context(), &output, component); err != nil {
log.Printf("render page: %v", err)
response.Header().Set("Cache-Control", "no-store")
http.Error(response, "could not render page", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "text/html; charset=utf-8")
response.WriteHeader(status)
_, _ = response.Write(output.Bytes())
}
```
The handler—not the template—should interpret request metadata, principals,
permissions, analytics, or storage errors. It passes only the resulting typed
display data into the component. Templates should not acquire an implicit
request global or turn middleware context into an inheritance framework.
Handwritten `sando.Component` implementations and `Trust*` values are explicit
trusted-output capabilities. Keep them conspicuous and review them separately
from ordinary untrusted values.
## Continue with the official lessons
- [Build a component, page, and small site](https://sandwichhime.com/docs/tutorial/).
- [Follow a request through a larger Go application](https://sandwichhime.com/docs/tutorial/application/).
- [Review the Sandwich Hime security boundary](https://sandwichhime.com/docs/security/).
Those tutorials own the template syntax and compiler workflow. This repository
documents only the application seam so the two projects do not drift into a
single mandatory framework.
+18 -1
View File
@@ -10,7 +10,18 @@ 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.
An application may create an account with a cryptographically generated
temporary credential and `RequirePasswordChange`. Successful rotation compares
the current credential, replaces its Argon2id hash, clears the requirement, and
revokes every session in one repository transaction. The application must
restrict such a principal to password change and logout until rotation succeeds;
the library does not infer route policy. Temporary credentials must be written
to a private channel or mode-`0600` file and must never be printed into logs,
manifests, process arguments, or deployment state.
Unsafe methods without an exact Origin or trustworthy same-origin Fetch
Metadata fail the origin check. Authentication middleware fails closed when its
@@ -22,6 +33,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 }
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$root"
failed=0
while IFS= read -r -d '' file; do
case $file in
./.git/*|./LICENSES/*|./go.sum) continue ;;
./.git|./.git/*|./LICENSES/*|./go.sum) continue ;;
./starters/*|./examples/*) expected=0BSD ;;
./scripts/*|./.gitea/*|./services/*) expected=AGPL-3.0-only ;;
*) expected=MPL-2.0 ;;
+10
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,10 +29,16 @@ 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
docs/THREAT_MODEL.md
go.mod
@@ -41,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
+1 -1
View File
@@ -10,5 +10,5 @@ go test -race ./...
go vet ./...
build_dir=$(mktemp -d)
trap 'rm -rf "$build_dir"' EXIT
go build -trimpath -o "$build_dir/basic" ./starters/basic
go build -buildvcs=false -trimpath -o "$build_dir/basic" ./starters/basic
git diff --check
+5
View File
@@ -15,3 +15,8 @@ go run ./starters/basic -listen 127.0.0.1:8080
Production configuration and secrets belong outside the source tree. This
starter does not load `.env` files automatically.
Continue with the [getting-started guide](../../docs/GETTING_STARTED.md) for
package selection and middleware order. To replace the plain-text response
with typed HTML without changing ownership of the server, follow
[HTML with Sandwich Hime](../../docs/SANDWICH_HIME.md).