Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb6bbd0dad | ||
|
|
5905fe6fb2 | ||
|
|
920e68f57f | ||
|
|
206d09e4cd |
@@ -2,6 +2,39 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.4 — 2026-08-18
|
||||
|
||||
- Add an explicit local-administrator password recovery operation without
|
||||
adding a public recovery endpoint or network protocol.
|
||||
- Atomically install a one-time Argon2id credential, restore mandatory password
|
||||
rotation, revoke every session, and append a secret-free audit event.
|
||||
- Prove transaction rollback when the audit event cannot commit and document
|
||||
private mode-`0600` delivery as application-owned policy.
|
||||
- Keep Previews 1–3 immutable; applications select Preview 4 explicitly when
|
||||
adopting administrative recovery.
|
||||
|
||||
## 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,
|
||||
|
||||
@@ -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.4` 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.4
|
||||
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.4
|
||||
```
|
||||
|
||||
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,38 @@ 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, local administrative recovery,
|
||||
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
|
||||
|
||||
@@ -14,3 +14,9 @@ service-level agreement. There is no bug bounty.
|
||||
|
||||
The preview supports only versions explicitly listed in release notes. Security
|
||||
claims stop at the documented trust boundaries and executable tests.
|
||||
|
||||
Password recovery is an explicitly local administrative capability. It must
|
||||
not be wired directly to a public route. Applications using it are responsible
|
||||
for local operator authorization and exclusive mode-`0600` credential delivery;
|
||||
the library transaction requires a new password change, revokes all sessions,
|
||||
and records a secret-free audit event.
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package access defines organization-scoped role bindings and audited,
|
||||
// short-lived break-glass authorization.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
|
||||
)
|
||||
|
||||
type SubjectKind string
|
||||
|
||||
const (
|
||||
User SubjectKind = "user"
|
||||
Team SubjectKind = "team"
|
||||
)
|
||||
|
||||
type Scope struct {
|
||||
OrganizationID string
|
||||
ProjectID string
|
||||
EnvironmentID string
|
||||
ServiceID string
|
||||
}
|
||||
|
||||
func (scope Scope) Validate() error {
|
||||
if !idPattern.MatchString(scope.OrganizationID) || scope.ProjectID != "" && !idPattern.MatchString(scope.ProjectID) || scope.EnvironmentID != "" && !idPattern.MatchString(scope.EnvironmentID) || scope.ServiceID != "" && !idPattern.MatchString(scope.ServiceID) {
|
||||
return errors.New("access: invalid scope")
|
||||
}
|
||||
if scope.EnvironmentID != "" && scope.ProjectID == "" || scope.ServiceID != "" && scope.EnvironmentID == "" {
|
||||
return errors.New("access: incomplete scope hierarchy")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (scope Scope) contains(requested Scope) bool {
|
||||
if scope.OrganizationID != requested.OrganizationID {
|
||||
return false
|
||||
}
|
||||
for _, pair := range [][2]string{{scope.ProjectID, requested.ProjectID}, {scope.EnvironmentID, requested.EnvironmentID}, {scope.ServiceID, requested.ServiceID}} {
|
||||
if pair[0] != "" && pair[0] != pair[1] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type Binding struct {
|
||||
ID string
|
||||
SubjectKind SubjectKind
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope Scope
|
||||
GrantedBy string
|
||||
GrantedAt time.Time
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
Roles map[string]string
|
||||
Permissions map[string]string
|
||||
Grants map[string][]string
|
||||
}
|
||||
|
||||
func (policy Policy) Validate() error {
|
||||
if len(policy.Roles) == 0 || len(policy.Roles) > 1000 || len(policy.Permissions) == 0 || len(policy.Permissions) > 10000 || len(policy.Grants) > 1000 {
|
||||
return errors.New("access: invalid policy size")
|
||||
}
|
||||
for name, description := range policy.Roles {
|
||||
if !namePattern.MatchString(name) || !text(description, 512, true) {
|
||||
return errors.New("access: invalid role")
|
||||
}
|
||||
}
|
||||
for name, description := range policy.Permissions {
|
||||
if !namePattern.MatchString(name) || !text(description, 512, true) {
|
||||
return errors.New("access: invalid permission")
|
||||
}
|
||||
}
|
||||
for role, permissions := range policy.Grants {
|
||||
if _, ok := policy.Roles[role]; !ok || len(permissions) > 10000 {
|
||||
return errors.New("access: invalid role grant")
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if _, ok := policy.Permissions[permission]; !ok {
|
||||
return errors.New("access: role references unknown permission")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type BreakGlass struct {
|
||||
ID, OrganizationID, UserID, Permission, Reason string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type AuditEvent struct {
|
||||
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
SeedAccessPolicy(context.Context, Policy) error
|
||||
Grant(context.Context, Binding) error
|
||||
Revoke(context.Context, string, string, time.Time) error
|
||||
EffectiveBindings(context.Context, string, string) ([]Binding, error)
|
||||
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
|
||||
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
|
||||
AppendAccessAudit(context.Context, AuditEvent) error
|
||||
AccessAudit(context.Context, string, int) ([]AuditEvent, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
policy Policy
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, policy Policy, options Options) (*Service, error) {
|
||||
if repository == nil {
|
||||
return nil, errors.New("access: repository is required")
|
||||
}
|
||||
if err := policy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
|
||||
}
|
||||
|
||||
func (service *Service) Seed(ctx context.Context) error {
|
||||
return service.repository.SeedAccessPolicy(ctx, service.policy)
|
||||
}
|
||||
|
||||
type Grant struct {
|
||||
SubjectKind SubjectKind
|
||||
SubjectID string
|
||||
Role string
|
||||
Scope Scope
|
||||
GrantedBy string
|
||||
}
|
||||
|
||||
func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error) {
|
||||
if (input.SubjectKind != User && input.SubjectKind != Team) || !idPattern.MatchString(input.SubjectID) || !idPattern.MatchString(input.GrantedBy) {
|
||||
return Binding{}, errors.New("access: invalid binding subject")
|
||||
}
|
||||
if _, ok := service.policy.Roles[input.Role]; !ok {
|
||||
return Binding{}, errors.New("access: unknown role")
|
||||
}
|
||||
if err := input.Scope.Validate(); err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
id, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
binding := Binding{ID: id, SubjectKind: input.SubjectKind, SubjectID: input.SubjectID, Role: input.Role, Scope: input.Scope, GrantedBy: input.GrantedBy, GrantedAt: service.now().UTC()}
|
||||
if err = service.repository.Grant(ctx, binding); err != nil {
|
||||
return Binding{}, err
|
||||
}
|
||||
return binding, nil
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
Source string
|
||||
Role string
|
||||
}
|
||||
|
||||
func (service *Service) Authorize(ctx context.Context, userID string, scope Scope, permission string) (Decision, error) {
|
||||
if !idPattern.MatchString(userID) || !namePattern.MatchString(permission) {
|
||||
return Decision{}, errors.New("access: invalid authorization request")
|
||||
}
|
||||
if err := scope.Validate(); err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
if _, ok := service.policy.Permissions[permission]; !ok {
|
||||
return Decision{}, errors.New("access: unknown permission")
|
||||
}
|
||||
bindings, err := service.repository.EffectiveBindings(ctx, scope.OrganizationID, userID)
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID })
|
||||
for _, binding := range bindings {
|
||||
if !binding.Scope.contains(scope) {
|
||||
continue
|
||||
}
|
||||
for _, granted := range service.policy.Grants[binding.Role] {
|
||||
if granted == permission {
|
||||
return Decision{Allowed: true, Source: "role", Role: binding.Role}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
breakGlass, err := service.repository.ActiveBreakGlass(ctx, scope.OrganizationID, userID, service.now().UTC())
|
||||
if err != nil {
|
||||
return Decision{}, err
|
||||
}
|
||||
for _, grant := range breakGlass {
|
||||
if grant.Permission == permission {
|
||||
return Decision{Allowed: true, Source: "break_glass"}, nil
|
||||
}
|
||||
}
|
||||
return Decision{}, nil
|
||||
}
|
||||
|
||||
func (service *Service) ActivateBreakGlass(ctx context.Context, organizationID, userID, permission, reason, requestID string, lifetime time.Duration) (BreakGlass, error) {
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !namePattern.MatchString(permission) || !text(strings.TrimSpace(reason), 1024, false) || !text(requestID, 128, true) || lifetime < time.Minute || lifetime > time.Hour {
|
||||
return BreakGlass{}, errors.New("access: invalid break-glass request")
|
||||
}
|
||||
if _, ok := service.policy.Permissions[permission]; !ok {
|
||||
return BreakGlass{}, errors.New("access: unknown permission")
|
||||
}
|
||||
id, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return BreakGlass{}, err
|
||||
}
|
||||
auditID, err := randomID(service.random)
|
||||
if err != nil {
|
||||
return BreakGlass{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
grant := BreakGlass{ID: id, OrganizationID: organizationID, UserID: userID, Permission: permission, Reason: strings.TrimSpace(reason), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||
audit := AuditEvent{ID: auditID, OrganizationID: organizationID, ActorUserID: userID, Action: "break_glass.activate", ResourceType: "organization", ResourceID: organizationID, RequestID: requestID, Summary: "Temporary emergency access activated", CreatedAt: now}
|
||||
if err = service.repository.CreateBreakGlass(ctx, grant, audit); err != nil {
|
||||
return BreakGlass{}, err
|
||||
}
|
||||
return grant, nil
|
||||
}
|
||||
|
||||
func (service *Service) Audit(ctx context.Context, organizationID string, limit int) ([]AuditEvent, error) {
|
||||
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("access: invalid audit query")
|
||||
}
|
||||
return service.repository.AccessAudit(ctx, organizationID, limit)
|
||||
}
|
||||
|
||||
func randomID(random io.Reader) (string, error) {
|
||||
value := make([]byte, 18)
|
||||
if _, err := io.ReadFull(random, value); err != nil {
|
||||
return "", fmt.Errorf("access: secure randomness unavailable: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func text(value string, limit int, emptyOK bool) bool {
|
||||
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestScopedRoleAndBreakGlass(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
repository := &repositoryStub{}
|
||||
policy := Policy{Roles: map[string]string{"viewer": "Read safe telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
|
||||
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scope := Scope{OrganizationID: "org-12345678", ProjectID: "project-12345678"}
|
||||
binding, err := service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: scope, GrantedBy: "user-87654321"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository.bindings = []Binding{binding}
|
||||
decision, err := service.Authorize(t.Context(), "user-12345678", Scope{OrganizationID: scope.OrganizationID, ProjectID: scope.ProjectID, EnvironmentID: "env-12345678"}, "telemetry.read")
|
||||
if err != nil || !decision.Allowed || decision.Source != "role" {
|
||||
t.Fatalf("decision=%+v err=%v", decision, err)
|
||||
}
|
||||
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
|
||||
if err != nil || decision.Allowed {
|
||||
t.Fatalf("unexpected sensitive decision=%+v err=%v", decision, err)
|
||||
}
|
||||
grant, err := service.ActivateBreakGlass(t.Context(), scope.OrganizationID, "user-12345678", "telemetry.sensitive.read", "Investigate active incident", "request-12345678", 15*time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repository.breakGlass = []BreakGlass{grant}
|
||||
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
|
||||
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
|
||||
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
|
||||
policy := Policy{Roles: map[string]string{"viewer": ""}, Permissions: map[string]string{"telemetry.read": ""}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
|
||||
service, err := New(&repositoryStub{}, policy, Options{Random: strings.NewReader(strings.Repeat("x", 256))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: Scope{OrganizationID: "org-12345678", EnvironmentID: "env-12345678"}, GrantedBy: "user-87654321"}); err == nil {
|
||||
t.Fatal("incomplete hierarchy accepted")
|
||||
}
|
||||
if _, err = service.ActivateBreakGlass(t.Context(), "org-12345678", "user-12345678", "telemetry.read", "reason", "", 2*time.Hour); err == nil {
|
||||
t.Fatal("unbounded break-glass lifetime accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
bindings []Binding
|
||||
breakGlass []BreakGlass
|
||||
}
|
||||
|
||||
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
|
||||
func (*repositoryStub) Grant(context.Context, Binding) error { return nil }
|
||||
func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error { return nil }
|
||||
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
|
||||
return repository.bindings, nil
|
||||
}
|
||||
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
|
||||
repository.breakGlass = []BreakGlass{grant}
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) {
|
||||
return repository.breakGlass, nil
|
||||
}
|
||||
func (*repositoryStub) AppendAccessAudit(context.Context, AuditEvent) error { return nil }
|
||||
func (*repositoryStub) AccessAudit(context.Context, string, int) ([]AuditEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
+108
-2
@@ -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,9 @@ 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
|
||||
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) 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 +107,17 @@ 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
|
||||
}
|
||||
|
||||
// AdministrativePasswordReset describes a locally authorized recovery. The
|
||||
// application is responsible for delivering TemporaryPassword through a
|
||||
// private, one-time channel; the value must never be logged or audited.
|
||||
type AdministrativePasswordReset struct {
|
||||
Identifier, TemporaryPassword string
|
||||
}
|
||||
|
||||
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
|
||||
username := strings.TrimSpace(input.Username)
|
||||
@@ -120,13 +135,104 @@ 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
|
||||
}
|
||||
|
||||
// ResetPassword replaces an active user's credential without requiring the
|
||||
// current password. It is intended only for a locally authorized
|
||||
// administrative recovery command. The repository atomically requires another
|
||||
// password change, revokes all sessions, and appends a secret-free audit event.
|
||||
func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) {
|
||||
identifier := strings.TrimSpace(input.Identifier)
|
||||
user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier)
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
|
||||
}
|
||||
if user.Status != "active" {
|
||||
return User{}, ErrInactiveUser
|
||||
}
|
||||
if VerifyPassword(currentHash, input.TemporaryPassword) {
|
||||
return User{}, ErrPasswordUnchanged
|
||||
}
|
||||
newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
auditID, err := randomToken(service.random, 18)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
audit := AuditEvent{
|
||||
ID: auditID,
|
||||
Action: "auth.password.reset",
|
||||
ResourceType: "user",
|
||||
ResourceID: user.ID,
|
||||
Summary: "A local administrator issued a one-time credential and revoked all sessions.",
|
||||
CreatedAt: now,
|
||||
}
|
||||
if err = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil {
|
||||
if errors.Is(err, ErrInvalidCredentials) {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
return User{}, fmt.Errorf("auth: reset password: %w", err)
|
||||
}
|
||||
user.PasswordChangeRequired = true
|
||||
user.UpdatedAt = now
|
||||
return user, 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")
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -59,6 +59,15 @@ 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) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) 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) {
|
||||
|
||||
@@ -109,6 +109,15 @@ 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) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) 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) {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/access"
|
||||
)
|
||||
|
||||
func (store *Store) SeedAccessPolicy(ctx context.Context, policy access.Policy) error {
|
||||
if err := policy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for name, description := range policy.Roles {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_roles(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for name, description := range policy.Permissions {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_permissions(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for role, permissions := range policy.Grants {
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_access_role_permissions WHERE role_name=?`, role); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_role_permissions(role_name,permission_name) VALUES(?,?)`, role, permission); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
|
||||
if !opaqueID(binding.ID) || (binding.SubjectKind != access.User && binding.SubjectKind != access.Team) || !opaqueID(binding.SubjectID) || !safeName(binding.Role) || binding.Scope.Validate() != nil || !opaqueID(binding.GrantedBy) || binding.GrantedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid access binding")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var exists int
|
||||
query := `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`
|
||||
if binding.SubjectKind == access.Team {
|
||||
query = `SELECT COUNT(*) FROM gwf_teams WHERE organization_id=? AND id=?`
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists != 1 {
|
||||
return errors.New("authsqlite: access subject is not active in organization")
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("authsqlite: grantor is not active in organization")
|
||||
}
|
||||
scopeQuery, arguments := `SELECT 1`, []any{}
|
||||
switch {
|
||||
case binding.Scope.ServiceID != "":
|
||||
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_application_services WHERE id=? AND environment_id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.ServiceID, binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
|
||||
case binding.Scope.EnvironmentID != "":
|
||||
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
|
||||
case binding.Scope.ProjectID != "":
|
||||
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_projects WHERE id=? AND organization_id=?`, []any{binding.Scope.ProjectID, binding.Scope.OrganizationID}
|
||||
}
|
||||
if err = tx.QueryRowContext(ctx, scopeQuery, arguments...).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists != 1 {
|
||||
return errors.New("authsqlite: access scope does not exist")
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) VALUES(?,?,?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,?)`, binding.ID, binding.Scope.OrganizationID, binding.SubjectKind, binding.SubjectID, binding.Role, binding.Scope.ProjectID, binding.Scope.EnvironmentID, binding.Scope.ServiceID, binding.GrantedBy, binding.GrantedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) Revoke(ctx context.Context, bindingID, actorUserID string, when time.Time) error {
|
||||
if !opaqueID(bindingID) || !opaqueID(actorUserID) || when.IsZero() {
|
||||
return errors.New("authsqlite: invalid access revocation")
|
||||
}
|
||||
result, err := store.db.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE id=? AND revoked_at IS NULL`, actorUserID, when.Unix(), bindingID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: access binding not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userID string) ([]access.Binding, error) {
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||
return nil, errors.New("authsqlite: invalid access query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at
|
||||
FROM gwf_access_bindings b
|
||||
WHERE b.organization_id=? AND b.revoked_at IS NULL
|
||||
AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active')
|
||||
AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id)))
|
||||
ORDER BY b.id`, organizationID, userID, userID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []access.Binding
|
||||
for rows.Next() {
|
||||
var binding access.Binding
|
||||
var project, environment, service sql.NullString
|
||||
var granted int64
|
||||
if err = rows.Scan(&binding.ID, &binding.SubjectKind, &binding.SubjectID, &binding.Role, &project, &environment, &service, &binding.GrantedBy, &granted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
binding.Scope = access.Scope{OrganizationID: organizationID, ProjectID: project.String, EnvironmentID: environment.String, ServiceID: service.String}
|
||||
binding.GrantedAt = time.Unix(granted, 0).UTC()
|
||||
result = append(result, binding)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
|
||||
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
|
||||
return errors.New("authsqlite: invalid break-glass event")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = appendAccessAudit(ctx, tx, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID string, now time.Time) ([]access.BreakGlass, error) {
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
|
||||
return nil, errors.New("authsqlite: invalid break-glass query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,permission_name,reason,created_at,expires_at FROM gwf_break_glass WHERE organization_id=? AND user_id=? AND expires_at>? ORDER BY expires_at`, organizationID, userID, now.Unix())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []access.BreakGlass
|
||||
for rows.Next() {
|
||||
var grant access.BreakGlass
|
||||
var created, expires int64
|
||||
if err = rows.Scan(&grant.ID, &grant.Permission, &grant.Reason, &created, &expires); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
grant.OrganizationID, grant.UserID = organizationID, userID
|
||||
grant.CreatedAt, grant.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
|
||||
result = append(result, grant)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) AppendAccessAudit(ctx context.Context, audit access.AuditEvent) error {
|
||||
if !validAccessAudit(audit) {
|
||||
return errors.New("authsqlite: invalid access audit")
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *Store) AccessAudit(ctx context.Context, organizationID string, limit int) ([]access.AuditEvent, error) {
|
||||
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("authsqlite: invalid access audit query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at FROM gwf_access_audit_events WHERE organization_id=? ORDER BY created_at DESC,id DESC LIMIT ?`, organizationID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []access.AuditEvent
|
||||
for rows.Next() {
|
||||
var event access.AuditEvent
|
||||
var requestID sql.NullString
|
||||
var created int64
|
||||
if err = rows.Scan(&event.ID, &event.ActorUserID, &event.Action, &event.ResourceType, &event.ResourceID, &requestID, &event.Summary, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
event.OrganizationID = organizationID
|
||||
event.RequestID = requestID.String
|
||||
event.CreatedAt = time.Unix(created, 0).UTC()
|
||||
result = append(result, event)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func appendAccessAudit(ctx context.Context, tx *sql.Tx, audit access.AuditEvent) error {
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func validBreakGlass(grant access.BreakGlass) bool {
|
||||
return opaqueID(grant.ID) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && safeName(grant.Permission) && text(grant.Reason, 1024, false) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) <= time.Hour
|
||||
}
|
||||
|
||||
func validAccessAudit(audit access.AuditEvent) bool {
|
||||
return opaqueID(audit.ID) && opaqueID(audit.OrganizationID) && opaqueID(audit.ActorUserID) && safeName(audit.Action) && safeName(audit.ResourceType) && text(audit.ResourceID, 256, false) && text(audit.RequestID, 128, true) && text(audit.Summary, 1024, true) && !audit.CreatedAt.IsZero()
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authsqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gamertan.com/web/organizations"
|
||||
)
|
||||
|
||||
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) error {
|
||||
if !opaqueID(organization.ID) || !slugValue(organization.Slug) || !text(organization.Name, 128, false) || organization.CreatedAt.IsZero() || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid organization")
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var personalOwner any
|
||||
if organization.Personal {
|
||||
personalOwner = owner.UserID
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at) VALUES(?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error {
|
||||
if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid team")
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership) error {
|
||||
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid team membership")
|
||||
}
|
||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at)
|
||||
SELECT t.id,?,? FROM gwf_teams t
|
||||
JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active'
|
||||
WHERE t.id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrMembershipNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error {
|
||||
if !opaqueID(project.ID) || !opaqueID(project.OrganizationID) || !slugValue(project.Slug) || !text(project.Name, 128, false) || project.CreatedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid project")
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_projects(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, project.ID, project.OrganizationID, project.Slug, project.Name, project.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func (store *Store) CreateEnvironment(ctx context.Context, environment organizations.Environment) error {
|
||||
if !opaqueID(environment.ID) || !opaqueID(environment.OrganizationID) || !opaqueID(environment.ProjectID) || !slugValue(environment.Slug) || !text(environment.Name, 128, false) || environment.CreatedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid environment")
|
||||
}
|
||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_environments(id,organization_id,project_id,slug,name,created_at)
|
||||
SELECT ?,?,?,?,?,? FROM gwf_projects WHERE id=? AND organization_id=?`, environment.ID, environment.OrganizationID, environment.ProjectID, environment.Slug, environment.Name, environment.CreatedAt.Unix(), environment.ProjectID, environment.OrganizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: project is outside organization")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateApplicationService(ctx context.Context, application organizations.ApplicationService) error {
|
||||
if !opaqueID(application.ID) || !opaqueID(application.OrganizationID) || !opaqueID(application.ProjectID) || !opaqueID(application.EnvironmentID) || !slugValue(application.Slug) || !text(application.Name, 128, false) || application.CreatedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid application service")
|
||||
}
|
||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_application_services(id,organization_id,project_id,environment_id,slug,name,created_at)
|
||||
SELECT ?,?,?,?,?,?,? FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, application.ID, application.OrganizationID, application.ProjectID, application.EnvironmentID, application.Slug, application.Name, application.CreatedAt.Unix(), application.EnvironmentID, application.ProjectID, application.OrganizationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return errors.New("authsqlite: environment is outside project")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) error {
|
||||
if zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() {
|
||||
return errors.New("authsqlite: invalid invitation")
|
||||
}
|
||||
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at)
|
||||
SELECT ?,?,?,?,?,? FROM gwf_organization_memberships
|
||||
WHERE organization_id=? AND user_id=? AND status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.OrganizationID, invitation.InvitedByUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrMembershipNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) {
|
||||
if zeroDigest(digest) || now.IsZero() {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
var invitation organizations.Invitation
|
||||
var created, expires int64
|
||||
err := store.db.QueryRowContext(ctx, `SELECT organization_id,email_normalized,invited_by_user_id,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &created, &expires)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.Invitation{}, organizations.ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return organizations.Invitation{}, err
|
||||
}
|
||||
invitation.Digest = digest
|
||||
invitation.CreatedAt = time.Unix(created, 0).UTC()
|
||||
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
|
||||
return invitation, nil
|
||||
}
|
||||
|
||||
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time) error {
|
||||
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
tx, err := store.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var organizationID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT i.organization_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized WHERE i.token_hash=? AND i.used_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&organizationID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil {
|
||||
return err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed, _ := result.RowsAffected(); changed != 1 {
|
||||
return organizations.ErrInvitationNotFound
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]organizations.Membership, error) {
|
||||
if !opaqueID(userID) {
|
||||
return nil, errors.New("authsqlite: invalid user")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT organization_id,status,joined_at FROM gwf_organization_memberships WHERE user_id=? ORDER BY organization_id`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []organizations.Membership
|
||||
for rows.Next() {
|
||||
var membership organizations.Membership
|
||||
var joined int64
|
||||
if err = rows.Scan(&membership.OrganizationID, &membership.Status, &joined); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
membership.UserID = userID
|
||||
membership.JoinedAt = time.Unix(joined, 0).UTC()
|
||||
result = append(result, membership)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
|
||||
if !opaqueID(organizationID) || !opaqueID(userID) {
|
||||
return nil, errors.New("authsqlite: invalid team query")
|
||||
}
|
||||
rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.created_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id WHERE t.organization_id=? AND tm.user_id=? ORDER BY t.slug`, organizationID, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var result []organizations.Team
|
||||
for rows.Next() {
|
||||
var team organizations.Team
|
||||
var created int64
|
||||
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
team.OrganizationID = organizationID
|
||||
team.CreatedAt = time.Unix(created, 0).UTC()
|
||||
result = append(result, team)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func slugValue(value string) bool {
|
||||
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
|
||||
return false
|
||||
}
|
||||
for _, character := range value {
|
||||
if character != '-' && (character < 'a' || character > 'z') && (character < '0' || character > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+158
-6
@@ -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, ¬Null, &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,101 @@ 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) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
|
||||
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
|
||||
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=1,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
|
||||
}
|
||||
if err = appendAudit(ctx, tx, audit); 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 +298,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
|
||||
}
|
||||
@@ -271,13 +411,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
|
||||
return err
|
||||
}
|
||||
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
|
||||
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() {
|
||||
if !validAuditEvent(event) {
|
||||
return errors.New("authsqlite: invalid audit event")
|
||||
}
|
||||
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
||||
return appendAudit(ctx, store.db, event)
|
||||
}
|
||||
|
||||
type auditExecer interface {
|
||||
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
||||
}
|
||||
|
||||
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
|
||||
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
|
||||
return err
|
||||
}
|
||||
|
||||
func validAuditEvent(event auth.AuditEvent) bool {
|
||||
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
|
||||
}
|
||||
|
||||
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
|
||||
func safeName(value string) bool {
|
||||
if value == "" || len(value) > 128 {
|
||||
|
||||
@@ -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,171 @@ 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 TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
now := time.Unix(4000, 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: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"})
|
||||
if err != nil || !reset.PasswordChangeRequired {
|
||||
t.Fatalf("reset=%+v err=%v", reset, err)
|
||||
}
|
||||
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
|
||||
t.Fatalf("session survived reset: %v", err)
|
||||
}
|
||||
if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("old credential survived reset: %v", err)
|
||||
}
|
||||
_, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour)
|
||||
if err != nil || !principal.User.PasswordChangeRequired {
|
||||
t.Fatalf("recovery principal=%+v err=%v", principal, err)
|
||||
}
|
||||
var action, summary string
|
||||
var events int
|
||||
if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") {
|
||||
t.Fatalf("events=%d action=%q summary=%q", events, action, summary)
|
||||
}
|
||||
if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) {
|
||||
t.Fatalf("same credential err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) {
|
||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
now := time.Unix(5000, 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: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
newHash, err := auth.HashPassword("one-time recovery credential")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now}
|
||||
if err = store.AppendAudit(t.Context(), audit); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil {
|
||||
t.Fatal("duplicate audit unexpectedly committed reset")
|
||||
}
|
||||
if _, err = service.Session(t.Context(), token); err != nil {
|
||||
t.Fatalf("rollback revoked session: %v", err)
|
||||
}
|
||||
_, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
|
||||
if err != nil || principal.User.PasswordChangeRequired {
|
||||
t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err)
|
||||
}
|
||||
if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Fatalf("uncommitted recovery credential accepted: %v", 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 +284,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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- 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.4
|
||||
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.
|
||||
|
||||
For operator-led recovery, expose `auth.ResetPassword` only through a local
|
||||
administrative command—not a public HTTP endpoint. The operation installs an
|
||||
application-generated one-time credential, sets `PasswordChangeRequired`,
|
||||
revokes every existing session, and appends a secret-free audit event in the
|
||||
same repository transaction. Deliver that credential through an exclusive
|
||||
root-owned mode-`0600` file, delete it after successful rotation, and never put
|
||||
it in command arguments, stdout, logs, manifests, or deployment state.
|
||||
|
||||
## 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.
|
||||
@@ -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.4
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+27
-1
@@ -10,7 +10,27 @@ 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.
|
||||
|
||||
Administrative recovery is deliberately a separate capability. The storage
|
||||
adapter atomically replaces the credential, restores the password-change
|
||||
requirement, revokes all sessions, and appends a generic audit event. The core
|
||||
library does not expose a recovery HTTP handler, deliver the credential, or
|
||||
authorize the local operator. Applications must keep that command local,
|
||||
generate the credential cryptographically, and write it only to a newly created
|
||||
private file. A recovery must not reveal whether an account exists through a
|
||||
public request surface.
|
||||
|
||||
Unsafe methods without an exact Origin or trustworthy same-origin Fetch
|
||||
Metadata fail the origin check. Authentication middleware fails closed when its
|
||||
@@ -22,6 +42,12 @@ configured reverse proxy, authorize application routes automatically, encrypt a
|
||||
compromised host, or decide how long an operator may lawfully retain personal
|
||||
request evidence.
|
||||
|
||||
Applications must pass the authenticated user and requested resource hierarchy
|
||||
to `access.Authorize`; possessing a platform-level `auth` role does not bypass
|
||||
that decision. Team membership is resolved by the repository rather than
|
||||
accepted from request input. Break-glass access lasts at most one hour and is
|
||||
not a substitute for ordinary role policy.
|
||||
|
||||
Local storage adapters assume the parent directory and host account are trusted.
|
||||
They reject a symlink at the configured final path and apply private file modes,
|
||||
but they do not defend against a concurrent privileged actor replacing path
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
// Package organizations defines storage-neutral organizations, teams,
|
||||
// memberships, and single-use invitations.
|
||||
package organizations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
ID string
|
||||
Slug string
|
||||
Name string
|
||||
Personal bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
OrganizationID string
|
||||
UserID string
|
||||
Status string
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID, OrganizationID, Slug, Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TeamMembership struct {
|
||||
TeamID, UserID string
|
||||
JoinedAt time.Time
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID, OrganizationID, Slug, Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
ID, OrganizationID, ProjectID, Slug, Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type ApplicationService struct {
|
||||
ID, OrganizationID, ProjectID, EnvironmentID, Slug, Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type Invitation struct {
|
||||
Digest [32]byte
|
||||
OrganizationID string
|
||||
Email, InvitedByUserID string
|
||||
CreatedAt, ExpiresAt, UsedAt time.Time
|
||||
}
|
||||
|
||||
type Repository interface {
|
||||
CreateOrganization(context.Context, Organization, Membership) error
|
||||
CreateTeam(context.Context, Team) error
|
||||
AddTeamMember(context.Context, TeamMembership) error
|
||||
CreateProject(context.Context, Project) error
|
||||
CreateEnvironment(context.Context, Environment) error
|
||||
CreateApplicationService(context.Context, ApplicationService) error
|
||||
CreateInvitation(context.Context, Invitation) error
|
||||
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
||||
AcceptInvitation(context.Context, [32]byte, string, time.Time) error
|
||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repository Repository
|
||||
random io.Reader
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func New(repository Repository, options Options) (*Service, error) {
|
||||
if repository == nil {
|
||||
return nil, errors.New("organizations: repository is required")
|
||||
}
|
||||
if options.Random == nil {
|
||||
options.Random = rand.Reader
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = time.Now
|
||||
}
|
||||
return &Service{repository: repository, random: options.Random, now: options.Now}, nil
|
||||
}
|
||||
|
||||
type CreateOrganization struct {
|
||||
Slug, Name, OwnerUserID string
|
||||
Personal bool
|
||||
}
|
||||
|
||||
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) {
|
||||
return Organization{}, errors.New("organizations: invalid organization")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now}
|
||||
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
|
||||
if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil {
|
||||
return Organization{}, err
|
||||
}
|
||||
return organization, nil
|
||||
}
|
||||
|
||||
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
|
||||
value := make([]byte, 6)
|
||||
if _, err := io.ReadFull(service.random, value); err != nil {
|
||||
return Organization{}, fmt.Errorf("organizations: secure randomness unavailable: %w", err)
|
||||
}
|
||||
suffix := hex.EncodeToString(value)
|
||||
return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true})
|
||||
}
|
||||
|
||||
type CreateTeam struct{ OrganizationID, Slug, Name string }
|
||||
|
||||
func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) {
|
||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
||||
input.Name = strings.TrimSpace(input.Name)
|
||||
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
||||
return Team{}, errors.New("organizations: invalid team")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return Team{}, err
|
||||
}
|
||||
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
||||
if err = service.repository.CreateTeam(ctx, team); err != nil {
|
||||
return Team{}, err
|
||||
}
|
||||
return team, nil
|
||||
}
|
||||
|
||||
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error {
|
||||
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
|
||||
return errors.New("organizations: invalid team membership")
|
||||
}
|
||||
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()})
|
||||
}
|
||||
|
||||
type CreateProject struct{ OrganizationID, Slug, Name string }
|
||||
|
||||
func (service *Service) CreateProject(ctx context.Context, input CreateProject) (Project, error) {
|
||||
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
|
||||
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
||||
return Project{}, errors.New("organizations: invalid project")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
project := Project{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
||||
if err = service.repository.CreateProject(ctx, project); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return project, nil
|
||||
}
|
||||
|
||||
type CreateEnvironment struct{ OrganizationID, ProjectID, Slug, Name string }
|
||||
|
||||
func (service *Service) CreateEnvironment(ctx context.Context, input CreateEnvironment) (Environment, error) {
|
||||
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
||||
return Environment{}, errors.New("organizations: invalid environment")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return Environment{}, err
|
||||
}
|
||||
environment := Environment{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
||||
if err = service.repository.CreateEnvironment(ctx, environment); err != nil {
|
||||
return Environment{}, err
|
||||
}
|
||||
return environment, nil
|
||||
}
|
||||
|
||||
type CreateApplicationService struct{ OrganizationID, ProjectID, EnvironmentID, Slug, Name string }
|
||||
|
||||
func (service *Service) CreateApplicationService(ctx context.Context, input CreateApplicationService) (ApplicationService, error) {
|
||||
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !idPattern.MatchString(input.EnvironmentID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
|
||||
return ApplicationService{}, errors.New("organizations: invalid application service")
|
||||
}
|
||||
id, err := token(service.random, 18)
|
||||
if err != nil {
|
||||
return ApplicationService{}, err
|
||||
}
|
||||
application := ApplicationService{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
|
||||
if err = service.repository.CreateApplicationService(ctx, application); err != nil {
|
||||
return ApplicationService{}, err
|
||||
}
|
||||
return application, nil
|
||||
}
|
||||
|
||||
func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) {
|
||||
email = strings.ToLower(strings.TrimSpace(email))
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
|
||||
return "", Invitation{}, errors.New("organizations: invalid invitation")
|
||||
}
|
||||
raw, err := token(service.random, 32)
|
||||
if err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
now := service.now().UTC()
|
||||
invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, CreatedAt: now, ExpiresAt: now.Add(lifetime)}
|
||||
if err = service.repository.CreateInvitation(ctx, invitation); err != nil {
|
||||
return "", Invitation{}, err
|
||||
}
|
||||
return raw, invitation, nil
|
||||
}
|
||||
|
||||
func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID string) error {
|
||||
if len(rawToken) < 32 || len(rawToken) > 128 || !idPattern.MatchString(userID) {
|
||||
return ErrInvitationNotFound
|
||||
}
|
||||
digest := sha256.Sum256([]byte(rawToken))
|
||||
now := service.now().UTC()
|
||||
if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return service.repository.AcceptInvitation(ctx, digest, userID, now)
|
||||
}
|
||||
|
||||
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
|
||||
if !idPattern.MatchString(userID) {
|
||||
return nil, errors.New("organizations: invalid user")
|
||||
}
|
||||
return service.repository.MembershipsForUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
|
||||
return nil, errors.New("organizations: invalid team query")
|
||||
}
|
||||
return service.repository.TeamsForUser(ctx, organizationID, userID)
|
||||
}
|
||||
|
||||
func (service *Service) Repository() Repository { return service.repository }
|
||||
|
||||
func token(random io.Reader, size int) (string, error) {
|
||||
value := make([]byte, size)
|
||||
if _, err := io.ReadFull(random, value); err != nil {
|
||||
return "", fmt.Errorf("organizations: secure randomness unavailable: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func bounded(value string, limit int) bool {
|
||||
return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package organizations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCreateInviteAndAccept(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
repository := &repositoryStub{}
|
||||
service, err := New(repository, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
organization, err := service.CreateOrganization(t.Context(), CreateOrganization{Slug: "quiet-systems", Name: "Quiet Systems", OwnerUserID: "user-12345678"})
|
||||
if err != nil || organization.ID == "" || repository.organization.ID != organization.ID {
|
||||
t.Fatalf("organization=%+v err=%v", organization, err)
|
||||
}
|
||||
raw, invitation, err := service.Invite(t.Context(), organization.ID, "MEMBER@example.test", "user-12345678", time.Hour)
|
||||
if err != nil || raw == "" || invitation.Email != "member@example.test" {
|
||||
t.Fatalf("invitation=%+v err=%v", invitation, err)
|
||||
}
|
||||
repository.invitation = invitation
|
||||
if err = service.AcceptInvitation(t.Context(), raw, "user-87654321"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if repository.acceptedUser != "user-87654321" {
|
||||
t.Fatalf("accepted=%q", repository.acceptedUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvitationFailsClosed(t *testing.T) {
|
||||
service, err := New(&repositoryStub{invitationErr: ErrInvitationNotFound}, Options{Random: strings.NewReader(strings.Repeat("x", 256))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.AcceptInvitation(t.Context(), strings.Repeat("x", 43), "user-87654321"); !errors.Is(err, ErrInvitationNotFound) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, _, err = service.Invite(t.Context(), "bad", "person@example.test", "user-12345678", time.Hour); err == nil {
|
||||
t.Fatal("invalid organization accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
organization Organization
|
||||
invitation Invitation
|
||||
invitationErr error
|
||||
acceptedUser string
|
||||
}
|
||||
|
||||
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error {
|
||||
repository.organization = organization
|
||||
return nil
|
||||
}
|
||||
func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil }
|
||||
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil }
|
||||
func (*repositoryStub) CreateProject(context.Context, Project) error { return nil }
|
||||
func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil }
|
||||
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error {
|
||||
repository.invitation = invitation
|
||||
return nil
|
||||
}
|
||||
func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error) {
|
||||
if repository.invitationErr != nil {
|
||||
return Invitation{}, repository.invitationErr
|
||||
}
|
||||
return repository.invitation, nil
|
||||
}
|
||||
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time) error {
|
||||
repository.acceptedUser = userID
|
||||
return nil
|
||||
}
|
||||
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user