Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/auth/service_security_test.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/auth/service_security_test.go |
| Original SHA-256 | 1c72d88717b54d8068a6e540575b314796a2c84ad9d11b1e204a1804b30ce69d |
| Git blob SHA | 9233f07b9fdd6aace5ea3b3d736d70a3e10972e0 |
| Reading-copy SHA-256 | 1c72d88717b54d8068a6e540575b314796a2c84ad9d11b1e204a1804b30ce69d |
Recovered source transcript
Download complete source text ↓401 lines · 13.5 KB
package auth
import (
"context"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/security"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// ---------------------------------------------------------------------------
// Fakes. These stand in for a pool and a transaction so the security decisions in this file are
// tested without a database. They record what the code asked the database to do, which is exactly
// what these findings are about: which rows a count is scoped to, and which statements a privilege
// change is obliged to issue.
// ---------------------------------------------------------------------------
type recordedQuery struct {
sql string
args []interface{}
}
// fakeRow answers a QueryRow. scan is the whole behavior: it either fills the destinations or
// returns the error the real database would have returned.
type fakeRow struct {
scan func(dest ...interface{}) error
}
func (r fakeRow) Scan(dest ...interface{}) error {
if r.scan == nil {
return nil
}
return r.scan(dest...)
}
// fakeRows answers a Query with a fixed column of strings, which is the only shape these paths read.
type fakeRows struct {
pgx.Rows
values []string
i int
}
func (r *fakeRows) Next() bool {
r.i++
return r.i <= len(r.values)
}
func (r *fakeRows) Scan(dest ...interface{}) error {
if len(dest) > 0 {
if p, ok := dest[0].(*string); ok {
*p = r.values[r.i-1]
}
}
return nil
}
func (r *fakeRows) Err() error { return nil }
func (r *fakeRows) Close() {}
// fakeDB records every statement and answers reads from configured hooks. It satisfies both
// dbgen.DBTX and pgx.Tx (the pgx.Tx methods it does not implement are inherited from the embedded
// nil interface and panic if a path ever reaches them, which is the point: a surprise call is loud).
type fakeDB struct {
pgx.Tx
recorded []recordedQuery
queryRow func(sql string, args []interface{}) fakeRow
query func(sql string, args []interface{}) (pgx.Rows, error)
child *fakeDB // the savepoint handed back by Begin
committed bool
rolled bool
}
func (f *fakeDB) record(sql string, args []interface{}) {
f.recorded = append(f.recorded, recordedQuery{sql: sql, args: args})
}
func (f *fakeDB) Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error) {
f.record(sql, args)
return pgconn.CommandTag{}, nil
}
func (f *fakeDB) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
f.record(sql, args)
if f.query != nil {
return f.query(sql, args)
}
return &fakeRows{}, nil
}
func (f *fakeDB) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
f.record(sql, args)
if f.queryRow != nil {
return f.queryRow(sql, args)
}
return fakeRow{}
}
func (f *fakeDB) Begin(ctx context.Context) (pgx.Tx, error) {
if f.child == nil {
f.child = &fakeDB{queryRow: f.queryRow, query: f.query}
}
return f.child, nil
}
func (f *fakeDB) Commit(ctx context.Context) error {
f.committed = true
return nil
}
func (f *fakeDB) Rollback(ctx context.Context) error {
f.rolled = true
return nil
}
// sawSQL reports whether any recorded statement contains fragment.
func (f *fakeDB) sawSQL(fragment string) bool {
for _, q := range f.recorded {
if strings.Contains(q.sql, fragment) {
return true
}
}
return false
}
func (f *fakeDB) findSQL(fragment string) (recordedQuery, bool) {
for _, q := range f.recorded {
if strings.Contains(q.sql, fragment) {
return q, true
}
}
return recordedQuery{}, false
}
// ---------------------------------------------------------------------------
// Finding 1: account lockout must not be scoped to the source address.
// ---------------------------------------------------------------------------
// The lockout count must be keyed on the email alone. Scoped to (email, ip) it was free to bypass by
// rotating source addresses, which also defeated the per ip rate limiter with the same move, so the
// two controls shared one exit.
func TestLockoutCountIsNotScopedToSourceAddress(t *testing.T) {
svc := &Service{}
var count int
db := &fakeDB{queryRow: func(sql string, args []interface{}) fakeRow {
return fakeRow{scan: func(dest ...interface{}) error {
if p, ok := dest[0].(*int); ok {
*p = count
}
return nil
}}
}}
count = maxFailedLogins
locked, err := svc.tooManyFailures(context.Background(), db, "target@example.gov")
if err != nil {
t.Fatalf("tooManyFailures: %v", err)
}
if !locked {
t.Fatal("threshold reached but the account was not locked")
}
q := db.recorded[0]
if strings.Contains(q.sql, "ip_address") {
t.Fatalf("the lockout count still filters on the source address: %s", q.sql)
}
for _, a := range q.args {
if s, ok := a.(*string); ok && s != nil && strings.Contains(*s, ".") {
t.Fatalf("an address shaped argument reached the lockout count: %v", *s)
}
}
count = maxFailedLogins - 1
locked, err = svc.tooManyFailures(context.Background(), db, "target@example.gov")
if err != nil {
t.Fatalf("tooManyFailures: %v", err)
}
if locked {
t.Fatal("locked below the threshold")
}
}
// The distinct address count is the signal that replaces the old ip scoping: it tells an operator
// reading the trail whether the failures came from one client or many.
func TestLockoutAuditCarriesSourceSpread(t *testing.T) {
svc := &Service{}
db := &fakeDB{queryRow: func(sql string, args []interface{}) fakeRow {
return fakeRow{scan: func(dest ...interface{}) error {
if p, ok := dest[0].(*int); ok {
*p = 37
}
return nil
}}
}}
spread, err := svc.failureSourceSpread(context.Background(), db, "target@example.gov", reasonBadPassword)
if err != nil {
t.Fatalf("failureSourceSpread: %v", err)
}
if spread != 37 {
t.Fatalf("spread = %d, want 37", spread)
}
if !db.sawSQL("count(DISTINCT ip_address)") {
t.Fatal("the spread signal does not count distinct source addresses")
}
if got := string(lockedJSON(reasonLocked, spread)); !strings.Contains(got, `"distinct_source_ips":37`) {
t.Fatalf("lockout audit payload lost the spread: %s", got)
}
}
// ---------------------------------------------------------------------------
// Finding 3: a tool entitlement change must revoke the target's sessions.
// ---------------------------------------------------------------------------
func toolsFakeDB(target uuid.UUID, actorTools, targetTools []string) *fakeDB {
f := &fakeDB{}
f.queryRow = func(sql string, args []interface{}) fakeRow {
return fakeRow{scan: func(dest ...interface{}) error {
if strings.Contains(sql, "FROM users WHERE id =") {
*(dest[0].(*uuid.UUID)) = target
*(dest[1].(*string)) = "user@example.gov"
*(dest[2].(*string)) = string(security.RoleMember)
*(dest[3].(*string)) = "active"
}
return nil
}}
}
f.query = func(sql string, args []interface{}) (pgx.Rows, error) {
if len(args) > 0 && args[0] == target {
return &fakeRows{values: targetTools}, nil
}
return &fakeRows{values: actorTools}, nil
}
return f
}
// Narrowing what an account may reach has to take effect now, not whenever its session is next
// resolved. Every other privilege change path (role, status, MFA reset) revokes; this one did not,
// which made it the way to hold onto access after it was taken away.
func TestSetUserToolsRevokesTargetSessions(t *testing.T) {
target := uuid.New()
actor := tenant.Principal{UserID: uuid.NewString(), Role: string(security.RoleOwner)}
db := toolsFakeDB(target, []string{"missionmatch", "screenmatch"}, []string{"missionmatch", "screenmatch"})
svc := &Service{deps: Deps{Audit: audit.New(nil)}}
if _, err := svc.setUserToolsTx(context.Background(), db, actor, target.String(), []string{"missionmatch"}, "10.0.0.1", "test"); err != nil {
t.Fatalf("setUserToolsTx: %v", err)
}
q, ok := db.findSQL("auth_revoke_user_sessions")
if !ok {
t.Fatal("a tool entitlement change did not revoke the target's sessions")
}
if len(q.args) == 0 || q.args[0] != target {
t.Fatalf("sessions revoked for the wrong subject: %v", q.args)
}
if !db.sawSQL("DELETE FROM user_tool_access") {
t.Fatal("the entitlement replacement itself did not run")
}
}
// ---------------------------------------------------------------------------
// Finding 4: invite must not answer whether an email exists in another organization.
// ---------------------------------------------------------------------------
func inviteFakeDB(t *testing.T, sameOrgHit bool, insertErr error) *fakeDB {
t.Helper()
f := &fakeDB{}
f.queryRow = func(sql string, args []interface{}) fakeRow {
switch {
case strings.Contains(sql, "lower(email)"):
return fakeRow{scan: func(dest ...interface{}) error {
if !sameOrgHit {
return pgx.ErrNoRows
}
*(dest[0].(*uuid.UUID)) = uuid.New()
return nil
}}
case strings.Contains(sql, "INSERT INTO users"):
return fakeRow{scan: func(dest ...interface{}) error {
if insertErr != nil {
return insertErr
}
*(dest[0].(*uuid.UUID)) = uuid.New()
return nil
}}
}
return fakeRow{}
}
return f
}
// A tenant admin must not be able to read the global user directory one address at a time. An email
// already claimed in ANOTHER organization has to look exactly like a free one from the outside, and
// the collision is recorded internally instead.
func TestInviteHidesCrossOrgEmailCollision(t *testing.T) {
actor := tenant.Principal{UserID: uuid.NewString(), Role: string(security.RoleOwner)}
svc := &Service{deps: Deps{Audit: audit.New(nil)}}
dup := &pgconn.PgError{Code: "23505", Message: "duplicate key value violates unique constraint"}
db := inviteFakeDB(t, false, dup)
id, elsewhere, err := svc.inviteTx(context.Background(), db, actor, uuid.NewString(),
InviteInput{Email: "someone@other-tenant.gov", FullName: "Someone", Role: string(security.RoleMember)},
nil, []byte("hash"), "10.0.0.1", "test")
if err != nil {
t.Fatalf("a cross organization collision surfaced as an error: %v", err)
}
if !elsewhere {
t.Fatal("the collision was not classified as claimed elsewhere")
}
if id != uuid.Nil {
t.Fatalf("a user was created despite the collision: %v", id)
}
if db.child == nil || !db.child.rolled {
t.Fatal("the failed insert was not rolled back to the savepoint")
}
if !db.sawSQL("INSERT INTO audit_logs") {
t.Fatal("the collision was not audited on the surviving transaction")
}
q, _ := db.findSQL("INSERT INTO audit_logs")
for _, a := range q.args {
if s, ok := a.(string); ok && strings.Contains(s, "other-tenant.gov") {
t.Fatal("the probed address was written into the tenant readable audit trail")
}
}
}
// The legitimate case still gets a real answer: an admin re inviting somebody who is already in
// THEIR OWN organization learns nothing cross tenant, so hiding it would only make a routine mistake
// look like a success.
func TestInviteStillConflictsWithinTheSameOrg(t *testing.T) {
actor := tenant.Principal{UserID: uuid.NewString(), Role: string(security.RoleOwner)}
svc := &Service{deps: Deps{Audit: audit.New(nil)}}
db := inviteFakeDB(t, true, nil)
_, elsewhere, err := svc.inviteTx(context.Background(), db, actor, uuid.NewString(),
InviteInput{Email: "colleague@my-tenant.gov", FullName: "Colleague", Role: string(security.RoleMember)},
nil, []byte("hash"), "10.0.0.1", "test")
if elsewhere {
t.Fatal("a same organization duplicate was misread as a cross organization collision")
}
if err != ErrEmailInUse {
t.Fatalf("err = %v, want ErrEmailInUse", err)
}
if db.sawSQL("INSERT INTO users") {
t.Fatal("an insert was attempted for an address already in the actor's own org")
}
}
// TestDenialAuditIsNotWrittenInsideTheFailingTransaction is a source shape guard, because this defect is
// invisible to an ordinary test: the audit write SUCCEEDS, the assertion passes, and the row disappears
// later when RunInTenantTx rolls back on the returned error. Only the SHAPE distinguishes correct from
// broken, so the shape is what is asserted.
//
// The rule: no authz.deny may be written with auditTx on a transaction whose closure then returns a
// refusal. Denials are captured as data and recorded by recordDenial on a fresh transaction afterwards.
func TestDenialAuditIsNotWrittenInsideTheFailingTransaction(t *testing.T) {
src, err := os.ReadFile("service_users.go")
if err != nil {
t.Fatalf("read source: %v", err)
}
lines := strings.Split(string(src), "\n")
for i, line := range lines {
if !strings.Contains(line, `Action: "authz.deny"`) {
continue
}
// Walk back to the auditTx call this belongs to, then decide which transaction it targets.
start := i
for start > 0 && !strings.Contains(lines[start], "auditTx(") {
start--
}
if !strings.Contains(lines[start], "auditTx(") {
continue
}
// recordDenial's own write is the intended one: it runs on its own transaction after the rollback.
inHelper := false
for j := start; j >= 0 && j > start-25; j-- {
if strings.HasPrefix(lines[j], "func ") {
inHelper = strings.Contains(lines[j], "recordDenial")
break
}
}
if inHelper {
continue
}
// Anything else must be followed by a COMMIT path, not a refusal. A refusal within the next few
// lines means the row it just wrote is about to be rolled back.
for k := start; k < len(lines) && k < start+6; k++ {
if strings.Contains(lines[k], "return ErrForbidden") {
t.Errorf("line %d writes an authz.deny inside a transaction that then returns ErrForbidden; "+
"RunInTenantTx will roll the audit row back and the denial leaves no trace. Capture a "+
"denial{} and let recordDenial write it after the rollback.", k+1)
}
}
}
}