Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,56 @@ jobs:
path: coverage.out
retention-days: 7

# ─── Store Conformance (Postgres + Mongo) ───────────────────────────
# Runs the cross-backend store conformance suite against real Postgres
# (testcontainers) and MongoDB (single-node replica set, so app/org cascade
# transactions work). Scoped to `-run TestConformance` so it does NOT run the
# older per-backend store_test.go suites — those now pass on postgres (env_id
# is seeded), but the mongo suite still has a hanging migration-lock test
# (TestMigrate_DoesNotBreakFreshLock) tracked separately.
#
# BLOCKING: all four backends (memory + sqlite from the `go` job, and
# postgres + mongo here) must pass the same store contract, so behavioral
# drift between implementations fails CI rather than shipping.
integration:
name: Store Conformance (pg + mongo)
runs-on: ubuntu-latest
needs: go

steps:
- name: Checkout
uses: actions/checkout@v6

- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25.7"
cache: true
cache-dependency-path: go.sum

- name: Download modules
run: go mod download

- name: Start MongoDB (single-node replica set)
run: |
docker run -d --name mongo -p 27017:27017 mongo:7 --replSet rs0
echo "Waiting for mongod..."
for i in $(seq 1 30); do
if docker exec mongo mongosh --quiet --eval 'db.runCommand({ ping: 1 })' >/dev/null 2>&1; then break; fi
sleep 2
done
docker exec mongo mongosh --quiet --eval 'rs.initiate({_id:"rs0",members:[{_id:0,host:"localhost:27017"}]})'
echo "Waiting for primary..."
for i in $(seq 1 30); do
if docker exec mongo mongosh --quiet --eval 'db.hello().isWritablePrimary' | grep -q true; then break; fi
sleep 2
done

- name: Run store conformance suite
env:
AUTHSOME_MONGO_URI: mongodb://localhost:27017/authsome_test?replicaSet=rs0&directConnection=true
run: go test -tags integration -run 'TestConformance$' -count=1 -timeout 12m ./store/...

# ─── Lint ───────────────────────────────────────────────────────────
lint:
name: Lint
Expand Down
43 changes: 41 additions & 2 deletions api/admin_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,15 +284,34 @@ func (a *API) handleAdminListUsers(ctx forge.Context, req *AdminListUsersRequest
return nil, ctx.JSON(http.StatusOK, resp)
}

// userInCallerApp loads a user and verifies they belong to the caller's tenant
// app. A missing user and a user in another app both return 404, so a tenant
// admin cannot view, mutate, or even confirm the existence of users outside
// their own app.
func (a *API) userInCallerApp(ctx forge.Context, userID id.UserID) (*user.User, error) {
appID, ok := a.callerAppID(ctx)
if !ok {
return nil, forge.Unauthorized("authentication required")
}
u, err := a.engine.AdminGetUser(ctx.Context(), userID)
if err != nil {
return nil, mapError(err)
}
if u.AppID.String() != appID.String() {
return nil, forge.NotFound("user not found")
}
return u, nil
}

func (a *API) handleAdminGetUser(ctx forge.Context, req *AdminGetUserRequest) (*user.User, error) {
userID, err := id.ParseUserID(req.UserID)
if err != nil {
return nil, forge.BadRequest("invalid user_id")
}

u, err := a.engine.AdminGetUser(ctx.Context(), userID)
u, err := a.userInCallerApp(ctx, userID)
if err != nil {
return nil, mapError(err)
return nil, err
}

return u, nil
Expand All @@ -314,6 +333,10 @@ func (a *API) handleAdminBanUser(ctx forge.Context, req *AdminBanUserRequest) (*
return nil, forge.BadRequest("cannot ban yourself")
}

if _, err = a.userInCallerApp(ctx, userID); err != nil {
return nil, err
}

var expiresAt *time.Time
if req.ExpiresAt != nil && *req.ExpiresAt != "" {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
Expand Down Expand Up @@ -342,6 +365,10 @@ func (a *API) handleAdminUnbanUser(ctx forge.Context, req *AdminUnbanUserRequest
return nil, forge.BadRequest("invalid user_id")
}

if _, err = a.userInCallerApp(ctx, userID); err != nil {
return nil, err
}

if err := a.engine.AdminUnbanUser(ctx.Context(), adminID, userID); err != nil {
return nil, mapError(err)
}
Expand All @@ -366,6 +393,10 @@ func (a *API) handleAdminDeleteUser(ctx forge.Context, req *AdminDeleteUserReque
return nil, forge.BadRequest("cannot delete yourself")
}

if _, err = a.userInCallerApp(ctx, userID); err != nil {
return nil, err
}

if err := a.engine.AdminDeleteUser(ctx.Context(), adminID, userID); err != nil {
return nil, mapError(err)
}
Expand Down Expand Up @@ -409,6 +440,10 @@ func (a *API) handleAdminImpersonate(ctx forge.Context, req *AdminImpersonateReq
return nil, forge.BadRequest("cannot impersonate yourself")
}

if _, err = a.userInCallerApp(ctx, targetID); err != nil {
return nil, err
}

u, sess, err := a.engine.Impersonate(ctx.Context(), adminID, targetID)
if err != nil {
return nil, mapError(err)
Expand Down Expand Up @@ -442,6 +477,10 @@ func (a *API) handleAdminUpdateUser(ctx forge.Context, req *AdminUpdateUserReque
return nil, forge.BadRequest("invalid user_id")
}

if _, err = a.userInCallerApp(ctx, userID); err != nil {
return nil, err
}

updates := authsome.AdminUserUpdates{
FirstName: req.FirstName,
LastName: req.LastName,
Expand Down
130 changes: 130 additions & 0 deletions api/admin_scope_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package api_test

import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/xraph/authsome"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/user"
)

// seedForeignUser creates a user that belongs to a different app than the test
// platform app, for cross-tenant admin tests.
func seedForeignUser(t *testing.T, eng *authsome.Engine, email string) *user.User {
t.Helper()
now := time.Now()
u := &user.User{
ID: id.NewUserID(),
AppID: otherAppID(t),
Email: email,
FirstName: "Foreign",
CreatedAt: now,
UpdatedAt: now,
}
require.NoError(t, eng.Store().CreateUser(context.Background(), u))
return u
}

// The admin user-management endpoints are gated by manage:user, evaluated
// against the caller's own app. Without a per-target tenant check, a tenant
// admin could view, ban, delete, edit, or impersonate a user in another app.
// Each handler must confirm the target user's app matches the caller's.

func TestAdminGetUser_SameAppSucceeds(t *testing.T) {
a, eng := newBootstrappedAPI(t)
handler := withTestKey(a.Handler())
_, ownerToken, _ := signUp(t, eng, "admin-get-owner@test.com", "SecureP@ss1")
ownerID := userIDFor(t, eng, ownerToken)

// A user in the caller's own (platform) app.
_, victimToken, _ := signUp(t, eng, "admin-get-victim@test.com", "SecureP@ss1")
victimID := userIDFor(t, eng, victimToken)

req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/admin/users/"+victimID.String(), nil)
req = asAdmin(t, req, eng, ownerID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code, "owner must read a user in their own app; body=%s", rec.Body.String())
}

func TestAdminGetUser_RejectsCrossTenant(t *testing.T) {
a, eng := newBootstrappedAPI(t)
handler := withTestKey(a.Handler())
_, ownerToken, _ := signUp(t, eng, "admin-x-owner@test.com", "SecureP@ss1")
ownerID := userIDFor(t, eng, ownerToken)

foreign := seedForeignUser(t, eng, "foreign-user@test.com")

req := httptest.NewRequestWithContext(context.Background(), "GET", "/v1/admin/users/"+foreign.ID.String(), nil)
req = asAdmin(t, req, eng, ownerID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusNotFound, rec.Code, "must not read a user from another app; body=%s", rec.Body.String())
}

func TestAdminBanUser_RejectsCrossTenant(t *testing.T) {
a, eng := newBootstrappedAPI(t)
handler := withTestKey(a.Handler())
_, ownerToken, _ := signUp(t, eng, "admin-ban-owner@test.com", "SecureP@ss1")
ownerID := userIDFor(t, eng, ownerToken)

foreign := seedForeignUser(t, eng, "foreign-ban@test.com")

body := []byte(`{"reason":"pwned"}`)
req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/admin/users/"+foreign.ID.String()+"/ban", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req = asAdmin(t, req, eng, ownerID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusNotFound, rec.Code)

got, err := eng.AdminGetUser(context.Background(), foreign.ID)
require.NoError(t, err)
assert.False(t, got.Banned, "a user in another app must not be banned")
}

func TestAdminImpersonate_RejectsCrossTenant(t *testing.T) {
a, eng := newBootstrappedAPI(t)
handler := withTestKey(a.Handler())
_, ownerToken, _ := signUp(t, eng, "admin-imp-owner@test.com", "SecureP@ss1")
ownerID := userIDFor(t, eng, ownerToken)

foreign := seedForeignUser(t, eng, "foreign-imp@test.com")

req := httptest.NewRequestWithContext(context.Background(), "POST", "/v1/admin/impersonate/"+foreign.ID.String(), nil)
req = asAdmin(t, req, eng, ownerID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusNotFound, rec.Code, "must not impersonate a user from another app; body=%s", rec.Body.String())
}

func TestAdminDeleteUser_RejectsCrossTenant(t *testing.T) {
a, eng := newBootstrappedAPI(t)
handler := withTestKey(a.Handler())
_, ownerToken, _ := signUp(t, eng, "admin-del-owner@test.com", "SecureP@ss1")
ownerID := userIDFor(t, eng, ownerToken)

foreign := seedForeignUser(t, eng, "foreign-del@test.com")

req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/admin/users/"+foreign.ID.String(), nil)
req = asAdmin(t, req, eng, ownerID)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusNotFound, rec.Code)

_, err := eng.AdminGetUser(context.Background(), foreign.ID)
assert.NoError(t, err, "a user in another app must survive the delete attempt")
}
6 changes: 5 additions & 1 deletion api/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1014,17 +1014,21 @@ func TestHandleRevokeSession_Success(t *testing.T) {
require.NoError(t, err)

req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/"+sess.ID.String(), nil)
req = req.WithContext(middleware.WithUserID(req.Context(), sess.UserID))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

assert.Equal(t, http.StatusOK, rec.Code)
}

func TestHandleRevokeSession_InvalidID(t *testing.T) {
a, _ := newTestAPI(t)
a, eng := newTestAPI(t)
handler := withTestKey(a.Handler())

_, token, _ := signUp(t, eng, "revoke-invalid@test.com", "SecureP@ss1")

req := httptest.NewRequestWithContext(context.Background(), "DELETE", "/v1/sessions/invalid-id", nil)
req = req.WithContext(middleware.WithUserID(req.Context(), userIDFor(t, eng, token)))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)

Expand Down
12 changes: 6 additions & 6 deletions api/app_client_config_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ func (a *API) registerAppClientConfigRoutes(router forge.Router) error {
// ──────────────────────────────────────────────────

func (a *API) handleGetAppClientConfig(ctx forge.Context, req *GetAppClientConfigRequest) (*appclientconfig.Config, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

cfg, err := a.engine.Store().GetAppClientConfig(ctx.Context(), appID)
Expand All @@ -77,9 +77,9 @@ func (a *API) handleGetAppClientConfig(ctx forge.Context, req *GetAppClientConfi
}

func (a *API) handleSetAppClientConfig(ctx forge.Context, req *SetAppClientConfigRequest) (*appclientconfig.Config, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

now := time.Now().UTC()
Expand Down Expand Up @@ -125,9 +125,9 @@ func (a *API) handleSetAppClientConfig(ctx forge.Context, req *SetAppClientConfi
}

func (a *API) handleDeleteAppClientConfig(ctx forge.Context, req *DeleteAppClientConfigRequest) (*StatusResponse, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

if err := a.engine.Store().DeleteAppClientConfig(ctx.Context(), appID); err != nil {
Expand Down
12 changes: 6 additions & 6 deletions api/app_session_config_handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ func (a *API) registerAppSessionConfigRoutes(router forge.Router) error {
// ──────────────────────────────────────────────────

func (a *API) handleGetAppSessionConfig(ctx forge.Context, req *GetAppSessionConfigRequest) (*appsessionconfig.Config, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

cfg, err := a.engine.Store().GetAppSessionConfig(ctx.Context(), appID)
Expand All @@ -77,9 +77,9 @@ func (a *API) handleGetAppSessionConfig(ctx forge.Context, req *GetAppSessionCon
}

func (a *API) handleSetAppSessionConfig(ctx forge.Context, req *SetAppSessionConfigRequest) (*appsessionconfig.Config, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

now := time.Now().UTC()
Expand Down Expand Up @@ -118,9 +118,9 @@ func (a *API) handleSetAppSessionConfig(ctx forge.Context, req *SetAppSessionCon
}

func (a *API) handleDeleteAppSessionConfig(ctx forge.Context, req *DeleteAppSessionConfigRequest) (*StatusResponse, error) {
appID, err := id.ParseAppID(req.AppID)
appID, err := a.scopedAppID(ctx, req.AppID)
if err != nil {
return nil, forge.BadRequest("invalid app_id")
return nil, err
}

if err := a.engine.Store().DeleteAppSessionConfig(ctx.Context(), appID); err != nil {
Expand Down
Loading
Loading