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
2 changes: 2 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ jobs:
run: |
go test -json -count=1 ./internal/httpapi ./internal/objectstorage | tee /tmp/integration-tests.json
jq -e 'select(.Action == "pass" and .Test == "TestWorkspaceOwnershipAndClientScopeInvariants")' /tmp/integration-tests.json >/dev/null
jq -e 'select(.Action == "pass" and .Test == "TestExternalIdentityRegistrationPolicyAndLinking")' /tmp/integration-tests.json >/dev/null
jq -e 'select(.Action == "pass" and .Test == "TestExternalEmailEnrollmentResendCooldownIsAtomic/successful_enrollment_persists_email_metadata_and_rejects_replay")' /tmp/integration-tests.json >/dev/null
jq -e 'select(.Action == "pass" and .Test == "TestMinIODirectTransferAndVisibility")' /tmp/integration-tests.json >/dev/null
- run: go run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...
- run: go run ./cmd/platform93 migrate
Expand Down
11 changes: 11 additions & 0 deletions docs/application-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,17 @@ authentication enabled, and personal API keys and delegation disabled. Provider
credentials and inheritance remain separate from both configuration objects.
User-managed invitations are disabled and the custom-claim allowlist is empty.

In the admin, choose **Application > Settings > Access policy > Registration**.
Public registration is the default and applies to email and social authentication.
For social authentication, `automatic` creates a new user when no provider identity
exists and registration is public. Invite-only blocks this creation, but existing
linked identities can still sign in. Organization policy can additionally prohibit
public registration. The `sign_in` flow never creates an account.

An existing account with the same email is not silently linked to a social provider.
The user must sign in to that account and explicitly link the provider. Provider
identities and account registration remain isolated to the selected application.

All configuration writes use the application ETag through `If-Match`. Public
configuration writes replace the complete public object. Internal writes are
strict partial updates and reject unknown keys.
6 changes: 5 additions & 1 deletion internal/httpapi/external_email_enrollment.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,10 @@ FROM external_auth_email_enrollments WHERE id=$1 AND application_id=$2 AND crede
kernel.WriteProblem(w, r, http.StatusConflict, "account_link_required", "An account already uses this email. Sign in to that account and link the provider.")
return
}
if !s.registrationEnabled(r) {
kernel.WriteProblem(w, r, http.StatusForbidden, "registration_disabled", "Public registration is disabled for this application.")
return
}
if limitErr := enforceUserLimit(r.Context(), tx, applicationID); limitErr != nil {
kernel.WriteProblem(w, r, http.StatusConflict, "user_limit_reached", "The application user limit has been reached.")
return
Expand All @@ -201,7 +205,7 @@ FROM external_auth_email_enrollments WHERE id=$1 AND application_id=$2 AND crede
VALUES($1,$2,$3,$3,$4,$5,now())`, userID, applicationID, email, firstName, lastName)
if err == nil {
_, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata)
VALUES($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), applicationID, userID, provider, subject, email)
VALUES($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), applicationID, userID, provider, subject, email)
Comment thread
SirNarsh marked this conversation as resolved.
}
if err == nil {
_, err = tx.Exec(r.Context(), `UPDATE external_auth_email_enrollments SET consumed_at=now() WHERE id=$1`, parts[0])
Expand Down
92 changes: 88 additions & 4 deletions internal/httpapi/external_email_enrollment_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,23 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i
t.Fatal(err)
}
defer func() {
_, _ = db.Exec(context.Background(), `DELETE FROM external_auth_email_enrollments WHERE id=$1`, enrollmentID)
_, _ = db.Exec(context.Background(), `DELETE FROM auth_provider_configs WHERE id=$1`, providerID)
_, _ = db.Exec(context.Background(), `DELETE FROM applications WHERE id=$1`, applicationID)
_, _ = db.Exec(context.Background(), `DELETE FROM organizations WHERE id=$1`, organizationID)
for _, statement := range []string{
`DELETE FROM user_sessions WHERE application_id=$1`,
`DELETE FROM user_identities WHERE application_id=$1`,
`DELETE FROM users WHERE application_id=$1`,
`DELETE FROM external_auth_email_enrollments WHERE application_id=$1`,
`DELETE FROM auth_provider_configs WHERE application_id=$1`,
`DELETE FROM outbox WHERE event_id IN (SELECT id FROM domain_events WHERE application_id=$1)`,
`DELETE FROM domain_events WHERE application_id=$1`,
`DELETE FROM applications WHERE id=$1`,
} {
if _, cleanupErr := db.Exec(context.Background(), statement, applicationID); cleanupErr != nil {
t.Errorf("fixture cleanup failed: %v", cleanupErr)
}
}
if _, cleanupErr := db.Exec(context.Background(), `DELETE FROM organizations WHERE id=$1`, organizationID); cleanupErr != nil {
t.Errorf("organization cleanup failed: %v", cleanupErr)
}
}()

first, err := db.Begin(context.Background())
Expand Down Expand Up @@ -126,4 +139,75 @@ VALUES($1,$2,$3,'microsoft',$4,'https://app.example/auth/callback',$5,$6,now()+i
if attempts != 20 {
t.Fatalf("attempts = %d, want the database maximum 20", attempts)
}

t.Run("successful_enrollment_persists_email_metadata_and_rejects_replay", func(t *testing.T) {
ciphertext, encryptErr := vault.Encrypt([]byte(`{"client_secret":"test-only-secret"}`), "auth-provider:"+providerID.String())
if encryptErr != nil {
t.Fatal(encryptErr)
}
if _, err = db.Exec(context.Background(), `UPDATE auth_provider_configs SET config_ciphertext=$2 WHERE id=$1`, providerID, ciphertext); err != nil {
t.Fatal(err)
}
if _, err = db.Exec(context.Background(), `UPDATE external_auth_email_enrollments SET attempts=0 WHERE id=$1`, enrollmentID); err != nil {
t.Fatal(err)
}
tx, beginErr := db.Begin(context.Background())
if beginErr != nil {
t.Fatal(beginErr)
}
defer tx.Rollback(context.Background())
if err = server.app.EnsureSigningKey(context.Background(), tx); err != nil {
t.Fatal(err)
}
if err = tx.Commit(context.Background()); err != nil {
t.Fatal(err)
}
verify := func() *httptest.ResponseRecorder {
request := requestWithRoute(t, http.MethodPost, "/", map[string]any{
"enrollment": enrollmentID.String() + ":" + credential,
"code": "VALID123",
}, map[string]string{"application_id": applicationID.String()}, kernel.Actor{})
response := httptest.NewRecorder()
server.verifyExternalEmailEnrollment(response, request)
return response
}
for _, policy := range []struct {
name, disable, restore string
id any
}{
{"application", `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"invite_only"') WHERE id=$1`, `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"public"') WHERE id=$1`, applicationID},
{"organization", `UPDATE organization_policies SET enabled_settings=jsonb_set(enabled_settings,'{public_registration}','false') WHERE organization_id=$1`, `UPDATE organization_policies SET enabled_settings=jsonb_set(enabled_settings,'{public_registration}','true') WHERE organization_id=$1`, organizationID},
} {
if _, err = db.Exec(context.Background(), policy.disable, policy.id); err != nil {
t.Fatal(err)
}
if response := verify(); response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "registration_disabled") {
t.Fatalf("outstanding enrollment after %s policy change returned %d: %s", policy.name, response.Code, response.Body.String())
}
var users, identities int
var consumed bool
if err = db.QueryRow(context.Background(), `SELECT (SELECT count(*) FROM users WHERE application_id=$1),(SELECT count(*) FROM user_identities WHERE application_id=$1),(SELECT consumed_at IS NOT NULL FROM external_auth_email_enrollments WHERE id=$2)`, applicationID, enrollmentID).Scan(&users, &identities, &consumed); err != nil {
t.Fatal(err)
}
if users != 0 || identities != 0 || consumed {
t.Fatalf("blocked enrollment changed state: users=%d identities=%d consumed=%t", users, identities, consumed)
}
if _, err = db.Exec(context.Background(), policy.restore, policy.id); err != nil {
t.Fatal(err)
}
}
if response := verify(); response.Code != http.StatusOK {
t.Fatalf("successful enrollment returned %d: %s", response.Code, response.Body.String())
}
var metadataEmail string
if err = db.QueryRow(context.Background(), `SELECT metadata->>'email' FROM user_identities WHERE application_id=$1 AND provider='microsoft'`, applicationID).Scan(&metadataEmail); err != nil {
t.Fatal(err)
}
if metadataEmail != "user@example.test" {
t.Fatalf("identity metadata email = %q", metadataEmail)
}
if response := verify(); response.Code != http.StatusUnauthorized {
t.Fatalf("enrollment replay returned %d", response.Code)
}
})
}
169 changes: 169 additions & 0 deletions internal/httpapi/external_identity_registration_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package httpapi

import (
"context"
"encoding/json"
"net/http"
"os"
"testing"

"github.com/supaapps/platform93/internal/database"
"github.com/supaapps/platform93/internal/kernel"
"github.com/supaapps/platform93/internal/platform"
"github.com/supaapps/platform93/internal/secure"
)

func TestExternalIdentityRegistrationPolicyAndLinking(t *testing.T) {
databaseURL := os.Getenv("PLATFORM93_DATABASE_URL")
if databaseURL == "" {
t.Skip("PLATFORM93_DATABASE_URL is not configured")
}
if err := database.Migrate(databaseURL); err != nil {
t.Fatal(err)
}
ctx := context.Background()
db, err := database.Open(ctx, databaseURL)
if err != nil {
t.Fatal(err)
}
defer db.Close()
vault, err := secure.NewVault(make([]byte, 32))
if err != nil {
t.Fatal(err)
}
server := &Server{app: platform.New(db, vault, "https://platform93.test")}
organizationID := kernel.NewID().String()
if _, err := db.Exec(ctx, `INSERT INTO organizations(id,name,slug) VALUES($1,'Social registration',$2)`, organizationID, "social-"+organizationID); err != nil {
t.Fatal(err)
}
applicationIDs := []string{}
defer func() {
for _, id := range applicationIDs {
_, _ = db.Exec(ctx, `DELETE FROM user_identities WHERE application_id=$1`, id)
_, _ = db.Exec(ctx, `DELETE FROM users WHERE application_id=$1`, id)
_, _ = db.Exec(ctx, `DELETE FROM applications WHERE id=$1`, id)
}
_, _ = db.Exec(ctx, `DELETE FROM organizations WHERE id=$1`, organizationID)
}()
createApplication := func(config string) string {
t.Helper()
id := kernel.NewID().String()
internal := defaultApplicationInternalConfig()
if err := json.Unmarshal([]byte(config), &internal); err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(internal)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(ctx, `INSERT INTO applications(id,organization_id,name,slug,internal_config) VALUES($1,$2,'Social registration',$3,$4)`, id, organizationID, "social-"+id, encoded); err != nil {
t.Fatal(err)
}
applicationIDs = append(applicationIDs, id)
return id
}
complete := func(applicationID, flow, subject, email string, requestedBy *string) (string, error) {
t.Helper()
request := requestWithRoute(t, http.MethodGet, "/", nil, map[string]string{"application_id": applicationID}, kernel.Actor{})
return server.completeExternalIdentity(request, "google", "", flow, requestedBy, subject, email, "New", "User")
}
assertCounts := func(applicationID string, users, identities int) {
t.Helper()
var actualUsers, actualIdentities int
if err := db.QueryRow(ctx, `SELECT (SELECT count(*) FROM users WHERE application_id=$1),(SELECT count(*) FROM user_identities WHERE application_id=$1)`, applicationID).Scan(&actualUsers, &actualIdentities); err != nil {
t.Fatal(err)
}
if actualUsers != users || actualIdentities != identities {
t.Fatalf("users/identities = %d/%d, want %d/%d", actualUsers, actualIdentities, users, identities)
}
}
createUser := func(applicationID, email string) string {
t.Helper()
id := kernel.NewID().String()
if _, err := db.Exec(ctx, `INSERT INTO users(id,application_id,email,normalized_email) VALUES($1,$2,$3,$3)`, id, applicationID, email); err != nil {
t.Fatal(err)
}
return id
}
publicApplication := createApplication(`{}`)
var registeredUser string
t.Run("default public registration and normalized metadata", func(t *testing.T) {
var err error
registeredUser, err = complete(publicApplication, "automatic", "google-subject", " New.User@Example.Test ", nil)
if err != nil {
t.Fatalf("automatic registration failed: %v", err)
}
var normalized, metadataEmail string
var verified bool
if err := db.QueryRow(ctx, `SELECT u.normalized_email,u.email_verified_at IS NOT NULL,i.metadata->>'email' FROM users u JOIN user_identities i ON i.user_id=u.id WHERE u.id=$1 AND i.application_id=$2`, registeredUser, publicApplication).Scan(&normalized, &verified, &metadataEmail); err != nil {
t.Fatal(err)
}
if normalized != "new.user@example.test" || metadataEmail != normalized || !verified {
t.Fatal("registration did not persist a verified user and normalized identity email")
}
assertCounts(publicApplication, 1, 1)
})
if registeredUser == "" {
return
}
t.Run("repeat login does not duplicate users", func(t *testing.T) {
id, err := complete(publicApplication, "automatic", "google-subject", "new.user@example.test", nil)
if err != nil || id != registeredUser {
t.Fatalf("repeat login = %s, %v; want existing user", id, err)
}
assertCounts(publicApplication, 1, 1)
})
t.Run("same email requires explicit authenticated linking", func(t *testing.T) {
app := createApplication(`{}`)
existing := createUser(app, "existing@example.test")
if _, err := complete(app, "automatic", "existing-subject", "existing@example.test", nil); err == nil || err.Error() != "account_link_required" {
t.Fatalf("same-email automatic login = %v; want account_link_required", err)
}
assertCounts(app, 1, 0)
if _, err := complete(app, "link", "existing-subject", "existing@example.test", nil); err == nil || err.Error() != "authenticated_link_required" {
t.Fatalf("unauthenticated link = %v; want authenticated_link_required", err)
}
id, err := complete(app, "link", "existing-subject", " EXISTING@Example.Test ", &existing)
if err != nil || id != existing {
t.Fatalf("explicit link = %s, %v; want existing user", id, err)
}
var metadataEmail string
if err := db.QueryRow(ctx, `SELECT metadata->>'email' FROM user_identities WHERE user_id=$1`, existing).Scan(&metadataEmail); err != nil || metadataEmail != "existing@example.test" {
t.Fatalf("linked email metadata = %q, %v", metadataEmail, err)
}
assertCounts(app, 1, 1)
})
t.Run("identities and links are application isolated", func(t *testing.T) {
app := createApplication(`{}`)
id, err := complete(app, "automatic", "google-subject", "new.user@example.test", nil)
if err != nil || id == registeredUser {
t.Fatalf("second application registration = %s, %v; want distinct user", id, err)
}
if _, err := complete(app, "link", "cross-application-subject", "new.user@example.test", &registeredUser); err == nil || err.Error() != "account_unavailable" {
t.Fatalf("cross-application link = %v; want account_unavailable", err)
}
assertCounts(app, 1, 1)
})
t.Run("invite only blocks registration but permits existing identities", func(t *testing.T) {
if _, err := db.Exec(ctx, `UPDATE applications SET internal_config=jsonb_set(internal_config,'{registration_mode}','"invite_only"') WHERE id=$1`, publicApplication); err != nil {
t.Fatal(err)
}
for _, flow := range []string{"automatic", "sign_up"} {
if _, err := complete(publicApplication, flow, "uninvited-subject", "uninvited@example.test", nil); err == nil || err.Error() != "registration_disabled" {
t.Fatalf("%s in invite-only app = %v; want registration_disabled", flow, err)
}
}
id, err := complete(publicApplication, "sign_in", "google-subject", "new.user@example.test", nil)
if err != nil || id != registeredUser {
t.Fatalf("existing identity sign-in = %s, %v", id, err)
}
assertCounts(publicApplication, 1, 1)
})
t.Run("sign in never creates a new account", func(t *testing.T) {
app := createApplication(`{}`)
if _, err := complete(app, "sign_in", "unknown-subject", "unknown@example.test", nil); err == nil || err.Error() != "provider_identity_not_found" {
t.Fatalf("unknown sign-in = %v; want provider_identity_not_found", err)
}
assertCounts(app, 0, 0)
})
}
4 changes: 2 additions & 2 deletions internal/httpapi/google_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ WHERE application_id=$1 AND provider=$2 AND provider_subject=$3`, chi.URLParam(r
}
userID = *requestedBy
_, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata)
VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized)
VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized)
} else {
if !strings.Contains(normalized, "@") {
return "", fmt.Errorf("provider_email_verification_required")
Expand All @@ -319,7 +319,7 @@ VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URL
userID, chi.URLParam(r, "application_id"), email, normalized, firstName, lastName)
if err == nil {
_, err = tx.Exec(r.Context(), `INSERT INTO user_identities(id,application_id,user_id,provider,provider_subject,metadata)
VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized)
VALUES ($1,$2,$3,$4,$5,jsonb_build_object('email',$6::text))`, kernel.NewID(), chi.URLParam(r, "application_id"), userID, provider, subject, normalized)
}
}
if err != nil || tx.Commit(r.Context()) != nil {
Expand Down
Loading
Loading