diff --git a/controller/authz.go b/controller/authz.go new file mode 100644 index 00000000..4b1fb6fc --- /dev/null +++ b/controller/authz.go @@ -0,0 +1,22 @@ +package controller + +import ( + "net/http" + + "github.com/MAX-API-Next/MAX-API/service/authz" + "github.com/gin-gonic/gin" +) + +// GetPermissionCatalog returns the stable resource/action schema used by +// administrative clients. It is read-only; policy mutations are intentionally +// deferred until the authorization editor and audit contract are complete. +func GetPermissionCatalog(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": gin.H{ + "resources": authz.Catalog(), + "roles": authz.Roles(), + }, + }) +} diff --git a/controller/authz_test.go b/controller/authz_test.go new file mode 100644 index 00000000..aac4cf83 --- /dev/null +++ b/controller/authz_test.go @@ -0,0 +1,21 @@ +package controller + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" +) + +func TestGetPermissionCatalogReturnsResourcesAndRoleBaselines(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + GetPermissionCatalog(c) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"resources"`) + assert.Contains(t, recorder.Body.String(), `"channel"`) + assert.Contains(t, recorder.Body.String(), `"roles"`) + assert.Contains(t, recorder.Body.String(), `"superuser":true`) +} diff --git a/controller/oauth.go b/controller/oauth.go index 5a2ab1bf..a4093a93 100644 --- a/controller/oauth.go +++ b/controller/oauth.go @@ -19,8 +19,9 @@ import ( ) const ( - oauthAuthFlowTTL = 10 * time.Minute - oauthStateSessionKey = "oauth_state" + oauthAuthFlowTTL = 10 * time.Minute + oauthStateSessionKey = "oauth_state" + authSessionIDSessionKey = "auth_session_id" ) type oauthStateRequest struct { @@ -49,6 +50,33 @@ func clearOAuthSessionState(c *gin.Context) { } } +func authSessionID(session sessions.Session) string { + value, ok := session.Get(authSessionIDSessionKey).(string) + if !ok { + return "" + } + value = strings.TrimSpace(value) + if len(value) != 48 { + return "" + } + return value +} + +func ensureAuthSessionID(session sessions.Session) (string, error) { + if session == nil { + return "", errors.New("auth session is unavailable") + } + if value := authSessionID(session); value != "" { + return value, nil + } + value, err := common.GenerateRandomKey(48) + if err != nil { + return "", fmt.Errorf("generate auth session id: %w", err) + } + session.Set(authSessionIDSessionKey, value) + return value, nil +} + // providerParams returns map with Provider key for i18n templates func providerParams(name string) map[string]any { return map[string]any{"Provider": name} @@ -142,6 +170,12 @@ func GenerateOAuthCode(c *gin.Context) { return } } + session := sessions.Default(c) + sessionID, err := ensureAuthSessionID(session) + if err != nil { + common.ApiError(c, err) + return + } payload, err := common.Marshal(oauthFlowPayload{AffiliateCode: request.Aff}) if err != nil { common.ApiError(c, err) @@ -153,6 +187,7 @@ func GenerateOAuthCode(c *gin.Context) { Provider: request.Provider, Intent: request.Intent, UserId: userID, + SessionId: sessionID, Payload: string(payload), ExpiresAt: expiresAt, }) @@ -160,14 +195,14 @@ func GenerateOAuthCode(c *gin.Context) { common.ApiError(c, err) return } - session := sessions.Default(c) session.Set(oauthStateSessionKey, state) if err := session.Save(); err != nil { if _, consumeErr := model.ConsumeAuthFlow(state, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, - Provider: request.Provider, - Intent: request.Intent, - UserId: userID, + Purpose: model.AuthFlowPurposeOAuth, + Provider: request.Provider, + Intent: request.Intent, + UserId: userID, + SessionId: sessionID, }); consumeErr != nil { common.SysError("failed to invalidate OAuth flow after session save failure: " + consumeErr.Error()) } @@ -213,11 +248,20 @@ func HandleOAuth(c *gin.Context) { }) return } + sessionID := authSessionID(sessions.Default(c)) + if pendingFlow.SessionId != "" && pendingFlow.SessionId != sessionID { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": i18n.T(c, i18n.MsgOAuthStateInvalid), + }) + return + } consumeMatch := model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, - Provider: providerName, - Intent: pendingFlow.Intent, + Purpose: model.AuthFlowPurposeOAuth, + Provider: providerName, + Intent: pendingFlow.Intent, + SessionId: pendingFlow.SessionId, } if pendingFlow.Intent == model.AuthFlowIntentBind { userID := c.GetInt("id") @@ -355,45 +399,68 @@ func handleOAuthBind(c *gin.Context, provider oauth.Provider, pendingFlow *model } } - if _, err := model.ConsumeAuthFlow(flowToken, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, - Provider: pendingFlow.Provider, - Intent: model.AuthFlowIntentBind, - UserId: pendingFlow.UserId, - }); err != nil { - c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) - return - } - clearOAuthSessionState(c) - user := model.User{Id: pendingFlow.UserId} - err = user.FillUserById() - if err != nil { - common.ApiError(c, err) + // Resolve the mutation path before consuming the one-time flow so an + // unsupported provider can never burn a valid state. + genericProvider, isGenericProvider := provider.(*oauth.GenericOAuthProvider) + updateField, hasUpdateField := oauthProviderUserUpdateField(provider) + if !isGenericProvider && !hasUpdateField { + common.ApiError(c, fmt.Errorf("unsupported built-in OAuth provider: %s", provider.GetName())) return } - // Handle binding based on provider type - if genericProvider, ok := provider.(*oauth.GenericOAuthProvider); ok { - // Custom provider: use user_oauth_bindings table - err = model.UpdateUserOAuthBinding(user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID) - if err != nil { - common.ApiError(c, err) - return - } - } else { - // Built-in provider: update user record directly - provider.SetProviderUserID(&user, oauthUser.ProviderUserID) - updateField, ok := oauthProviderUserUpdateField(provider) - if !ok { - common.ApiError(c, fmt.Errorf("unsupported built-in OAuth provider: %s", provider.GetName())) - return - } - err = user.UpdateFields(false, updateField) - if err != nil { - common.ApiError(c, err) + var cacheTask model.CacheInvalidationTask + bindMatch := model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: pendingFlow.Provider, + Intent: model.AuthFlowIntentBind, + UserId: pendingFlow.UserId, + SessionId: pendingFlow.SessionId, + } + err = model.WithUserOAuthIdentityMutationLock(nil, func(conn *gorm.DB) error { + return conn.Transaction(func(tx *gorm.DB) error { + _, consumeErr := model.ConsumeAuthFlowWithActionTx(tx, flowToken, bindMatch, func(identityTx *gorm.DB, _ *model.AuthFlow) error { + var user model.User + if err := identityTx.First(&user, pendingFlow.UserId).Error; err != nil { + return err + } + if isGenericProvider { + // Custom provider: keep the binding mutation in the same transaction + // as AuthFlow consumption. + return model.UpdateUserOAuthBindingWithTx(identityTx, user.Id, genericProvider.GetProviderId(), oauthUser.ProviderUserID) + } + if taken, err := model.IsOAuthIdentityTakenWithTx(identityTx, updateField, oauthUser.ProviderUserID, user.Id); err != nil { + return err + } else if taken { + return model.ErrOAuthIdentityAlreadyTaken + } + if legacyID, ok := oauthUser.Extra["legacy_id"].(string); ok && strings.TrimSpace(legacyID) != "" { + if taken, err := model.IsOAuthIdentityTakenWithTx(identityTx, updateField, legacyID, user.Id); err != nil { + return err + } else if taken { + return model.ErrOAuthIdentityAlreadyTaken + } + } + + // Built-in provider: update the user identity and durable cache fence + // in the same transaction as AuthFlow consumption. + provider.SetProviderUserID(&user, oauthUser.ProviderUserID) + var updateErr error + cacheTask, updateErr = user.UpdateFieldsWithTxAndCache(identityTx, false, updateField) + return updateErr + }) + return consumeErr + }) + }) + if err != nil { + if errors.Is(err, model.ErrOAuthIdentityAlreadyTaken) { + common.ApiErrorI18n(c, i18n.MsgOAuthAlreadyBound, providerParams(provider.GetName())) return } + handleAuthFlowConsumeError(c, err) + return } + model.DispatchStagedCacheInvalidation(cacheTask) + clearOAuthSessionState(c) common.ApiSuccessI18n(c, i18n.MsgOAuthBindSuccess, gin.H{ "action": "bind", @@ -593,3 +660,14 @@ func handleOAuthError(c *gin.Context, err error) { common.ApiError(c, err) } } + +func handleAuthFlowConsumeError(c *gin.Context, err error) { + switch { + case errors.Is(err, model.ErrAuthFlowInvalid), + errors.Is(err, model.ErrAuthFlowExpired), + errors.Is(err, model.ErrAuthFlowConsumed): + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + default: + common.ApiError(c, err) + } +} diff --git a/controller/oauth_test.go b/controller/oauth_test.go index af8420e3..d3f1a6be 100644 --- a/controller/oauth_test.go +++ b/controller/oauth_test.go @@ -128,6 +128,56 @@ func TestGenerateOAuthCodeCreatesProviderBoundOpaqueState(t *testing.T) { require.NoError(t, err) require.NotEqual(t, response.Data, flow.TokenHash) require.Contains(t, flow.Payload, "invite") + require.Len(t, flow.SessionId, 48) +} + +func TestHandleOAuthRejectsSessionBoundFlowFromModifiedSession(t *testing.T) { + setupControllerAuthFlowDB(t) + require.NoError(t, appi18n.Init()) + originalGitHubOAuthEnabled := common.GitHubOAuthEnabled + common.GitHubOAuthEnabled = true + t.Cleanup(func() { common.GitHubOAuthEnabled = originalGitHubOAuthEnabled }) + + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("oauth-session-binding-test")))) + router.POST("/api/oauth/state", GenerateOAuthCode) + router.GET("/tamper", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(authSessionIDSessionKey, strings.Repeat("x", 48)) + require.NoError(t, session.Save()) + c.Status(http.StatusNoContent) + }) + router.GET("/api/oauth/:provider", HandleOAuth) + + stateRecorder := httptest.NewRecorder() + stateRequest := httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"github","intent":"login"}`)) + stateRequest.Header.Set("Content-Type", "application/json") + router.ServeHTTP(stateRecorder, stateRequest) + require.Equal(t, http.StatusOK, stateRecorder.Code) + var stateResponse struct { + Success bool `json:"success"` + Data string `json:"data"` + } + require.NoError(t, common.Unmarshal(stateRecorder.Body.Bytes(), &stateResponse)) + require.True(t, stateResponse.Success) + + tamperRequest := httptest.NewRequest(http.MethodGet, "/tamper", nil) + for _, sessionCookie := range stateRecorder.Result().Cookies() { + tamperRequest.AddCookie(sessionCookie) + } + tamperRecorder := httptest.NewRecorder() + router.ServeHTTP(tamperRecorder, tamperRequest) + require.Equal(t, http.StatusNoContent, tamperRecorder.Code) + + callbackRequest := httptest.NewRequest(http.MethodGet, "/api/oauth/github?state="+stateResponse.Data+"&error=access_denied", nil) + for _, sessionCookie := range tamperRecorder.Result().Cookies() { + callbackRequest.AddCookie(sessionCookie) + } + callbackRecorder := httptest.NewRecorder() + router.ServeHTTP(callbackRecorder, callbackRequest) + require.Equal(t, http.StatusForbidden, callbackRecorder.Code) + _, err := model.GetAuthFlow(stateResponse.Data, model.AuthFlowMatch{Purpose: model.AuthFlowPurposeOAuth, Provider: "github"}) + require.NoError(t, err) } func TestHandleOAuthRejectsStateFromDifferentBrowserSession(t *testing.T) { @@ -290,6 +340,29 @@ func TestOAuthProviderUserUpdateField(t *testing.T) { } } +func TestHandleAuthFlowConsumeErrorDistinguishesValidationAndInfrastructure(t *testing.T) { + require.NoError(t, appi18n.Init()) + + for _, validationErr := range []error{ + model.ErrAuthFlowInvalid, + model.ErrAuthFlowExpired, + model.ErrAuthFlowConsumed, + } { + validationRecorder := httptest.NewRecorder() + validationContext, _ := gin.CreateTestContext(validationRecorder) + validationContext.Request = httptest.NewRequest(http.MethodGet, "/", nil) + handleAuthFlowConsumeError(validationContext, validationErr) + require.Equal(t, http.StatusForbidden, validationRecorder.Code) + } + + infrastructureRecorder := httptest.NewRecorder() + infrastructureContext, _ := gin.CreateTestContext(infrastructureRecorder) + infrastructureContext.Request = httptest.NewRequest(http.MethodGet, "/", nil) + handleAuthFlowConsumeError(infrastructureContext, errors.New("database unavailable")) + require.Equal(t, http.StatusOK, infrastructureRecorder.Code) + require.Contains(t, infrastructureRecorder.Body.String(), "database unavailable") +} + func TestFindOrCreateOAuthUserMapsDeletedUserDomainError(t *testing.T) { _, err := findOrCreateOAuthUser(nil, &deletedUserOAuthProvider{}, &oauth.OAuthUser{ ProviderUserID: "deleted-user-id", diff --git a/controller/secure_verification_test.go b/controller/secure_verification_test.go index 946cb6e6..044b91c7 100644 --- a/controller/secure_verification_test.go +++ b/controller/secure_verification_test.go @@ -972,6 +972,12 @@ func TestTelegramAuthPayloadPreservesUnknownSignedFields(t *testing.T) { require.True(t, checkTelegramAuthorizationAt(values, token, now)) } +func TestTelegramAssertionHashNormalizationIsShared(t *testing.T) { + payload := telegramAuthPayload{Hash: " AbCdEf123 "} + require.Equal(t, "abcdef123", telegramAssertionKey(payload)) + require.Equal(t, telegramAssertionKey(payload), normalizeTelegramAssertionHash(" AbCdEf123 ")) +} + func TestTelegramAuthorizationRejectsAuthDateBeyondClockSkew(t *testing.T) { const token = "123456:telegram-test-token" now := time.Unix(1_800_000_000, 0) @@ -1070,6 +1076,78 @@ func TestTelegramLoginRequiresBrowserBoundStateAndConsumesItOnce(t *testing.T) { require.ErrorIs(t, err, model.ErrAuthFlowConsumed) } +func TestTelegramLoginRejectsProviderAssertionReplayAcrossStates(t *testing.T) { + setupControllerAuthFlowDB(t) + require.NoError(t, appi18n.Init()) + gin.SetMode(gin.TestMode) + + const token = "123456:telegram-assertion-replay-test" + originalEnabled := common.TelegramOAuthEnabled + originalToken := common.TelegramBotToken + common.TelegramOAuthEnabled = true + common.TelegramBotToken = token + t.Cleanup(func() { + common.TelegramOAuthEnabled = originalEnabled + common.TelegramBotToken = originalToken + }) + + user := model.User{ + Id: 99002, Username: "telegram-assertion-replay-user", TelegramId: "123456", + Role: common.RoleCommonUser, Status: common.UserStatusEnabled, Group: "default", + } + require.NoError(t, model.DB.Create(&user).Error) + + router := gin.New() + router.Use(sessions.Sessions("session", cookie.NewStore([]byte("telegram-assertion-replay")))) + router.POST("/api/oauth/state", GenerateOAuthCode) + router.GET("/api/oauth/telegram/login", TelegramLogin) + + generateState := func() (string, []*http.Cookie) { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/oauth/state", strings.NewReader(`{"provider":"telegram","intent":"login"}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) + var response struct { + Success bool `json:"success"` + Data string `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + require.True(t, response.Success) + return response.Data, recorder.Result().Cookies() + } + + stateA, cookiesA := generateState() + stateB, cookiesB := generateState() + params := signedTelegramParams(t, token, time.Now()) + + paramsA := params.Encode() + paramsWithStateA, err := url.ParseQuery(paramsA) + require.NoError(t, err) + paramsWithStateA.Set("state", stateA) + callbackA := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/login?"+paramsWithStateA.Encode(), nil) + for _, sessionCookie := range cookiesA { + callbackA.AddCookie(sessionCookie) + } + recorderA := httptest.NewRecorder() + router.ServeHTTP(recorderA, callbackA) + require.Equal(t, http.StatusOK, recorderA.Code, recorderA.Body.String()) + + paramsWithStateB, err := url.ParseQuery(paramsA) + require.NoError(t, err) + paramsWithStateB.Set("state", stateB) + callbackB := httptest.NewRequest(http.MethodGet, "/api/oauth/telegram/login?"+paramsWithStateB.Encode(), nil) + for _, sessionCookie := range cookiesB { + callbackB.AddCookie(sessionCookie) + } + recorderB := httptest.NewRecorder() + router.ServeHTTP(recorderB, callbackB) + require.Equal(t, http.StatusForbidden, recorderB.Code) + + _, err = model.GetAuthFlow(stateB, telegramLoginFlowMatch) + require.NoError(t, err) +} + type failingSaveSession struct { values map[interface{}]interface{} } diff --git a/controller/telegram.go b/controller/telegram.go index ba24c0f5..1b5caaff 100644 --- a/controller/telegram.go +++ b/controller/telegram.go @@ -20,6 +20,7 @@ import ( "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "gorm.io/gorm" ) const ( @@ -83,6 +84,23 @@ func (p telegramAuthPayload) values() url.Values { return values } +func telegramAssertionKey(payload telegramAuthPayload) string { + // Telegram's hash comparison is case-insensitive at the hex decoding + // boundary; normalize it before claiming so casing changes cannot bypass + // one-time assertion replay protection. + return normalizeTelegramAssertionHash(payload.Hash) +} + +func normalizeTelegramAssertionHash(hash string) string { + return strings.ToLower(strings.TrimSpace(hash)) +} + +func telegramAssertionExpiry(now time.Time) time.Time { + // Keep the claim alive for the whole accepted authorization window, + // including the configured future-clock skew. + return now.Add(telegramAuthorizationTTL + telegramClockSkew) +} + func GenerateTelegramBindState(c *gin.Context) { if !common.TelegramOAuthEnabled { common.ApiErrorMsg(c, "管理员未开启 Telegram 登录") @@ -93,25 +111,32 @@ func GenerateTelegramBindState(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": i18n.T(c, i18n.MsgUnauthorized)}) return } + session := sessions.Default(c) + sessionID, err := ensureAuthSessionID(session) + if err != nil { + common.ApiError(c, err) + return + } state, _, err := model.CreateAuthFlow(model.AuthFlowCreate{ Purpose: model.AuthFlowPurposeOAuth, Provider: "telegram", Intent: model.AuthFlowIntentBind, UserId: userID, + SessionId: sessionID, ExpiresAt: time.Now().Add(telegramBindFlowTTL), }) if err != nil { common.ApiError(c, err) return } - session := sessions.Default(c) session.Set(oauthStateSessionKey, state) if err := session.Save(); err != nil { if _, consumeErr := model.ConsumeAuthFlow(state, model.AuthFlowMatch{ - Purpose: model.AuthFlowPurposeOAuth, - Provider: "telegram", - Intent: model.AuthFlowIntentBind, - UserId: userID, + Purpose: model.AuthFlowPurposeOAuth, + Provider: "telegram", + Intent: model.AuthFlowIntentBind, + UserId: userID, + SessionId: sessionID, }); consumeErr != nil { common.SysError("failed to invalidate Telegram bind flow after session save failure: " + consumeErr.Error()) } @@ -140,7 +165,29 @@ func TelegramBind(c *gin.Context) { return } userID := c.GetInt("id") - generation, err := model.BindTelegramIdentityWithAuthFlow(userID, strconv.FormatInt(payload.ID, 10), payload.State) + pendingFlow, err := model.GetAuthFlow(payload.State, model.AuthFlowMatch{ + Purpose: model.AuthFlowPurposeOAuth, + Provider: "telegram", + Intent: model.AuthFlowIntentBind, + UserId: userID, + }) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } + currentSessionID := authSessionID(sessions.Default(c)) + if pendingFlow.SessionId != "" && pendingFlow.SessionId != currentSessionID { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } + generation, err := model.BindTelegramIdentityWithAuthFlowAndSessionAndAssertion( + userID, + strconv.FormatInt(payload.ID, 10), + payload.State, + pendingFlow.SessionId, + telegramAssertionKey(payload), + telegramAssertionExpiry(time.Now()), + ) if err != nil { switch { case errors.Is(err, model.ErrTelegramIdAlreadyTaken): @@ -177,10 +224,29 @@ func TelegramLogin(c *gin.Context) { if err := user.FillUserByTelegramId(); handleOAuthUserLookupError(c, err) { return } - if _, err := model.ConsumeAuthFlow(state, telegramLoginFlowMatch); err != nil { + pendingFlow, err := model.GetAuthFlow(state, telegramLoginFlowMatch) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) + return + } + currentSessionID := authSessionID(sessions.Default(c)) + if pendingFlow.SessionId != "" && pendingFlow.SessionId != currentSessionID { c.JSON(http.StatusForbidden, gin.H{"success": false, "message": i18n.T(c, i18n.MsgOAuthStateInvalid)}) return } + consumeMatch := telegramLoginFlowMatch + consumeMatch.SessionId = pendingFlow.SessionId + if _, err := model.ConsumeAuthFlowWithAction(state, consumeMatch, func(tx *gorm.DB, _ *model.AuthFlow) error { + return model.ClaimExternalAuthAssertionWithTx( + tx, + model.AuthFlowPurposeTelegramAssertion, + normalizeTelegramAssertionHash(params.Get("hash")), + telegramAssertionExpiry(time.Now()), + ) + }); err != nil { + handleAuthFlowConsumeError(c, err) + return + } clearOAuthSessionState(c) setupLogin(&user, c) } diff --git a/controller/user.go b/controller/user.go index 43b932db..567ce572 100644 --- a/controller/user.go +++ b/controller/user.go @@ -17,6 +17,7 @@ import ( "github.com/MAX-API-Next/MAX-API/logger" "github.com/MAX-API-Next/MAX-API/model" "github.com/MAX-API-Next/MAX-API/service" + "github.com/MAX-API-Next/MAX-API/service/authz" "github.com/MAX-API-Next/MAX-API/setting" "github.com/MAX-API-Next/MAX-API/setting/operation_setting" @@ -538,6 +539,7 @@ func GetSelf(c *gin.Context) { // 计算用户权限信息 permissions := calculateUserPermissions(userRole) + permissions["admin_permissions"] = authz.Capabilities(id, userRole) // 获取用户设置并提取sidebar_modules userSetting := user.GetSetting() diff --git a/controller/user_setting_test.go b/controller/user_setting_test.go index 2c8f0fb8..439d70d7 100644 --- a/controller/user_setting_test.go +++ b/controller/user_setting_test.go @@ -179,6 +179,39 @@ func TestGetSelfRedactsNotificationSecrets(t *testing.T) { assert.Equal(t, 7, returned.GotifyPriority) } +func TestGetSelfIncludesFineGrainedAdminPermissions(t *testing.T) { + db := setupUserSettingControllerTestDB(t) + user := model.User{ + Id: 1151, + Username: "self-permissions-user", + Password: "password", + Role: common.RoleAdminUser, + Status: common.UserStatusEnabled, + } + require.NoError(t, db.Create(&user).Error) + require.NoError(t, db.AutoMigrate(&model.AuthzUserOverride{})) + require.NoError(t, db.Create(&model.AuthzUserOverride{UserID: user.Id, Resource: "channel", Action: "sensitive_write", Allowed: true}).Error) + + recorder := httptest.NewRecorder() + ctx, _ := gin.CreateTestContext(recorder) + ctx.Request = httptest.NewRequest(http.MethodGet, "/api/user/self", nil) + ctx.Set("id", user.Id) + ctx.Set("role", user.Role) + GetSelf(ctx) + + var response struct { + Data struct { + Permissions map[string]any `json:"permissions"` + } `json:"data"` + } + require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response)) + adminPermissions, ok := response.Data.Permissions["admin_permissions"].(map[string]any) + require.True(t, ok) + channelPermissions, ok := adminPermissions["channel"].(map[string]any) + require.True(t, ok) + assert.Equal(t, true, channelPermissions["sensitive_write"]) +} + func TestGetSelfIgnoresQueryUserID(t *testing.T) { db := setupUserSettingControllerTestDB(t) diff --git a/middleware/authz_shadow.go b/middleware/authz_shadow.go new file mode 100644 index 00000000..7785802d --- /dev/null +++ b/middleware/authz_shadow.go @@ -0,0 +1,26 @@ +package middleware + +import ( + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/service/authz" + "github.com/gin-gonic/gin" +) + +// AuthzShadow evaluates the fine-grained permission without changing the +// legacy route decision. This allows route coverage and policy mismatches to +// be observed before enforcement is enabled for a route. +func AuthzShadow(permission authz.Permission) func(*gin.Context) { + return func(c *gin.Context) { + userID := c.GetInt("id") + role := c.GetInt("role") + decision := authz.Evaluate(userID, role, permission) + if decision.Err != nil { + common.SysLog("authz shadow evaluation failed: " + decision.Err.Error()) + } else if !decision.Allowed { + common.SysLog("authz shadow denied legacy-allowed request: user=" + c.GetString("username") + " resource=" + permission.Resource + " action=" + permission.Action + " source=" + decision.Source) + } + c.Set("authz_shadow_allowed", decision.Allowed) + c.Set("authz_shadow_source", decision.Source) + c.Next() + } +} diff --git a/middleware/authz_shadow_test.go b/middleware/authz_shadow_test.go new file mode 100644 index 00000000..f1eefe64 --- /dev/null +++ b/middleware/authz_shadow_test.go @@ -0,0 +1,40 @@ +package middleware + +import ( + "net/http/httptest" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/service/authz" + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func TestAuthzShadowPreservesLegacyRequestAndRecordsDecision(t *testing.T) { + previous := model.DB + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&model.AuthzUserOverride{})) + model.DB = db + t.Cleanup(func() { model.DB = previous }) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + c.Set("id", 22) + c.Set("role", common.RoleAdminUser) + c.Set("username", "admin") + c.Next() + }) + r.GET("/channels", AuthzShadow(authz.Permission{Resource: authz.ResourceChannel, Action: authz.ActionRead}), func(c *gin.Context) { + c.JSON(200, gin.H{"allowed": c.GetBool("authz_shadow_allowed"), "source": c.GetString("authz_shadow_source")}) + }) + recorder := httptest.NewRecorder() + r.ServeHTTP(recorder, httptest.NewRequest("GET", "/channels", nil)) + assert.Equal(t, 200, recorder.Code) + assert.Contains(t, recorder.Body.String(), `"allowed":true`) +} diff --git a/model/auth_flow.go b/model/auth_flow.go index a436b714..5bfb8d6b 100644 --- a/model/auth_flow.go +++ b/model/auth_flow.go @@ -10,15 +10,23 @@ import ( "time" "github.com/MAX-API-Next/MAX-API/common" + "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" ) const ( - AuthFlowPurposeOAuth = "oauth" - AuthFlowIntentLogin = "login" - AuthFlowIntentBind = "bind" - AuthFlowTokenBytes = 32 - AuthFlowDefaultCleanupRetention = 24 * time.Hour + AuthFlowPurposeOAuth = "oauth" + AuthFlowPurposeTwoFALogin = "2fa_login" + AuthFlowPurposePasskeyLogin = "passkey_login" + AuthFlowPurposePasskeyRegister = "passkey_register" + AuthFlowPurposePasskeyStepUp = "passkey_step_up" + AuthFlowPurposeTelegramBind = "telegram_bind" + AuthFlowPurposeTelegramAssertion = "telegram_assertion" + AuthFlowIntentLogin = "login" + AuthFlowIntentBind = "bind" + AuthFlowTokenBytes = 32 + AuthFlowDefaultCleanupRetention = 24 * time.Hour SecureVerificationMethod2FA = "2fa" SecureVerificationMethodPasskey = "passkey" @@ -47,6 +55,7 @@ type AuthFlow struct { Provider string `json:"provider,omitempty" gorm:"type:varchar(64)"` Intent string `json:"intent,omitempty" gorm:"type:varchar(16)"` UserId int `json:"user_id,omitempty" gorm:"index"` + SessionId string `json:"session_id,omitempty" gorm:"type:varchar(64);index"` Payload string `json:"-" gorm:"type:text"` CreatedAt time.Time `json:"created_at"` ExpiresAt time.Time `json:"expires_at" gorm:"not null;index:idx_auth_flow_purpose_expiry"` @@ -60,15 +69,17 @@ type AuthFlowCreate struct { Provider string Intent string UserId int + SessionId string Payload string ExpiresAt time.Time } type AuthFlowMatch struct { - Purpose string - Provider string - Intent string - UserId int + Purpose string + Provider string + Intent string + UserId int + SessionId string } func authFlowTokenHash(token string) string { @@ -86,6 +97,9 @@ func applyAuthFlowMatch(query *gorm.DB, token string, match AuthFlowMatch) *gorm if match.UserId != 0 { query = query.Where("user_id = ?", match.UserId) } + if match.SessionId != "" { + query = query.Where("session_id = ?", match.SessionId) + } return query } @@ -111,6 +125,7 @@ func CreateAuthFlow(input AuthFlowCreate) (string, *AuthFlow, error) { Provider: input.Provider, Intent: input.Intent, UserId: input.UserId, + SessionId: input.SessionId, Payload: input.Payload, ExpiresAt: input.ExpiresAt, } @@ -148,13 +163,50 @@ func ConsumeAuthFlow(token string, match AuthFlowMatch) (*AuthFlow, error) { return consumeAuthFlowWithDB(DB, token, match) } -func consumeAuthFlowWithDB(db *gorm.DB, token string, match AuthFlowMatch) (*AuthFlow, error) { - flow, err := getAuthFlowWithDB(db, token, match) +// ConsumeAuthFlowWithAction atomically consumes a flow and executes action in +// the same database transaction. If action fails, the flow remains usable. +// Optional match fields are enforced when non-zero, including SessionId. +func ConsumeAuthFlowWithAction(token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) { + if token == "" || match.Purpose == "" || DB == nil { + return nil, ErrAuthFlowInvalid + } + var flow AuthFlow + err := DB.Transaction(func(tx *gorm.DB) error { + consumed, err := ConsumeAuthFlowWithActionTx(tx, token, match, action) + if consumed != nil { + flow = *consumed + } + return err + }) if err != nil { return nil, err } + return &flow, nil +} + +// ConsumeAuthFlowWithActionTx consumes a flow and executes its action inside +// an existing transaction. The caller owns transaction begin/commit, which +// allows an outer connection-level lock to remain held until commit. +func ConsumeAuthFlowWithActionTx(tx *gorm.DB, token string, match AuthFlowMatch, action func(tx *gorm.DB, flow *AuthFlow) error) (*AuthFlow, error) { + if tx == nil || token == "" || match.Purpose == "" { + return nil, ErrAuthFlowInvalid + } + var flow AuthFlow + query := lockAuthFlowQuery(applyAuthFlowMatch(tx, token, match)) + if err := query.First(&flow).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrAuthFlowInvalid + } + return nil, err + } + if flow.ConsumedAt != nil { + return nil, ErrAuthFlowConsumed + } now := time.Now() - result := db.Model(&AuthFlow{}). + if !flow.ExpiresAt.After(now) { + return nil, ErrAuthFlowExpired + } + result := tx.Model(&AuthFlow{}). Where("id = ? AND consumed_at IS NULL AND expires_at > ?", flow.Id, now). Update("consumed_at", now) if result.Error != nil { @@ -164,7 +216,99 @@ func consumeAuthFlowWithDB(db *gorm.DB, token string, match AuthFlowMatch) (*Aut return nil, ErrAuthFlowConsumed } flow.ConsumedAt = &now - return flow, nil + if action != nil { + if err := action(tx, &flow); err != nil { + return nil, err + } + } + return &flow, nil +} + +// ClaimExternalAuthAssertion atomically records a provider assertion as +// consumed. The assertion itself is never persisted; its HMAC is protected by +// the unique token_hash index so replay is rejected on all supported databases. +func ClaimExternalAuthAssertion(purpose, assertion string, expiresAt time.Time) error { + if DB == nil { + return ErrAuthFlowInvalid + } + return DB.Transaction(func(tx *gorm.DB) error { + return ClaimExternalAuthAssertionWithTx(tx, purpose, assertion, expiresAt) + }) +} + +// ClaimExternalAuthAssertionWithTx performs the assertion claim in a caller +// transaction so replay protection can commit with the resulting state change. +func ClaimExternalAuthAssertionWithTx(tx *gorm.DB, purpose, assertion string, expiresAt time.Time) error { + purpose = strings.TrimSpace(purpose) + assertion = strings.TrimSpace(assertion) + now := time.Now() + if tx == nil || purpose == "" || assertion == "" || !expiresAt.After(now) { + return ErrAuthFlowInvalid + } + claimToken := uuid.NewString() + flow := AuthFlow{ + TokenHash: authFlowTokenHash("external:" + purpose + ":" + assertion), + Purpose: purpose, + Payload: claimToken, + ExpiresAt: expiresAt, + ConsumedAt: &now, + } + dialect := "" + if tx.Dialector != nil { + dialect = tx.Dialector.Name() + } + result := tx.Clauses(authFlowAssertionConflictClause(dialect)).Create(&flow) + if result.Error != nil { + return result.Error + } + claimed, err := ownsExternalAuthAssertionClaim(tx, dialect, flow.TokenHash, claimToken, result.RowsAffected) + if err != nil { + return err + } + if !claimed { + return ErrAuthFlowConsumed + } + return nil +} + +func ownsExternalAuthAssertionClaim(tx *gorm.DB, dialect, tokenHash, claimToken string, rowsAffected int64) (bool, error) { + if dialect != "mysql" { + return rowsAffected == 1, nil + } + + // MySQL may report a duplicate-key no-op as affected when clientFoundRows + // is enabled. The random payload proves that this attempt inserted the row. + var stored AuthFlow + if err := tx.Select("payload").Where("token_hash = ?", tokenHash).Take(&stored).Error; err != nil { + return false, err + } + return stored.Payload == claimToken, nil +} + +func authFlowAssertionConflictClause(dialect string) clause.OnConflict { + conflict := clause.OnConflict{ + Columns: []clause.Column{{Name: "token_hash"}}, + DoNothing: true, + } + if dialect == "mysql" { + // gorm.io/driver/mysql v1.4.3 translates DoNothing into + // ON DUPLICATE KEY UPDATE id=id. Avoid touching AuthFlow.Id because + // MySQL can reject that auto-increment no-op with error 1869. + conflict.DoNothing = false + conflict.DoUpdates = clause.AssignmentColumns([]string{"token_hash"}) + } + return conflict +} + +func lockAuthFlowQuery(query *gorm.DB) *gorm.DB { + if query == nil || query.Dialector == nil || query.Dialector.Name() == "sqlite" { + return query + } + return query.Clauses(clause.Locking{Strength: "UPDATE"}) +} + +func consumeAuthFlowWithDB(db *gorm.DB, token string, match AuthFlowMatch) (*AuthFlow, error) { + return ConsumeAuthFlowWithActionTx(db, token, match, nil) } func DeleteExpiredAuthFlows(now time.Time) error { diff --git a/model/auth_flow_test.go b/model/auth_flow_test.go index 6658bda3..6a250376 100644 --- a/model/auth_flow_test.go +++ b/model/auth_flow_test.go @@ -1,13 +1,16 @@ package model import ( + "database/sql" "errors" + "strings" "sync" "testing" "time" "github.com/glebarez/sqlite" "github.com/stretchr/testify/require" + gormmysql "gorm.io/driver/mysql" "gorm.io/gorm" ) @@ -22,7 +25,8 @@ func setupAuthFlowTestDB(t *testing.T) { originalDB := DB db, err := gorm.Open(sqlite.Open("file:auth_flow_test?mode=memory&cache=shared&_pragma=busy_timeout(5000)"), &gorm.Config{}) require.NoError(t, err) - require.NoError(t, db.AutoMigrate(&AuthFlow{})) + require.NoError(t, db.AutoMigrate(&AuthFlow{}, &User{}, &UserOAuthBinding{})) + require.True(t, db.Migrator().HasColumn(&AuthFlow{}, "session_id")) DB = db t.Cleanup(func() { DB = originalDB @@ -36,7 +40,8 @@ func TestAuthFlowIsProviderBoundAndConsumedOnce(t *testing.T) { setupAuthFlowTestDB(t) token, created, err := CreateAuthFlow(AuthFlowCreate{ Purpose: AuthFlowPurposeOAuth, Provider: "github", Intent: AuthFlowIntentLogin, - Payload: `{"affiliate_code":"invite"}`, ExpiresAt: time.Now().Add(time.Minute), + SessionId: "session-a", + Payload: `{"affiliate_code":"invite"}`, ExpiresAt: time.Now().Add(time.Minute), }) require.NoError(t, err) require.Len(t, token, 43) @@ -44,6 +49,8 @@ func TestAuthFlowIsProviderBoundAndConsumedOnce(t *testing.T) { _, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "discord"}) require.ErrorIs(t, err, ErrAuthFlowInvalid) + _, err = GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "github", SessionId: "session-b"}) + require.ErrorIs(t, err, ErrAuthFlowInvalid) const consumers = 2 errorsByConsumer := make([]error, consumers) @@ -53,7 +60,7 @@ func TestAuthFlowIsProviderBoundAndConsumedOnce(t *testing.T) { go func(index int) { defer wg.Done() _, errorsByConsumer[index] = ConsumeAuthFlow(token, AuthFlowMatch{ - Purpose: AuthFlowPurposeOAuth, Provider: "github", Intent: AuthFlowIntentLogin, + Purpose: AuthFlowPurposeOAuth, Provider: "github", Intent: AuthFlowIntentLogin, SessionId: "session-a", }) }(i) } @@ -93,3 +100,153 @@ func TestGenerateAuthFlowTokenReturnsEntropyFailure(t *testing.T) { require.Empty(t, token) require.ErrorContains(t, err, "entropy unavailable") } + +func TestExternalAuthAssertionCanOnlyBeClaimedOnce(t *testing.T) { + setupAuthFlowTestDB(t) + expiresAt := time.Now().Add(time.Minute) + + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt)) + require.ErrorIs(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "signed-assertion", expiresAt), ErrAuthFlowConsumed) + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "different-assertion", expiresAt)) +} + +func TestConsumeAuthFlowWithActionRollsBackTogether(t *testing.T) { + setupAuthFlowTestDB(t) + token, _, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + actionErr := errors.New("binding failed") + + _, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{ + Purpose: AuthFlowPurposeTelegramBind, UserId: 42, SessionId: "session-a", + }, func(tx *gorm.DB, _ *AuthFlow) error { + if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute)); err != nil { + return err + } + return actionErr + }) + require.ErrorIs(t, err, actionErr) + + flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind}) + require.NoError(t, err) + require.Nil(t, flow.ConsumedAt) + require.NoError(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-a", time.Now().Add(time.Minute))) +} + +func TestConsumeAuthFlowWithActionRollsBackOAuthBindingMutation(t *testing.T) { + setupAuthFlowTestDB(t) + user := User{Id: 43, Username: "oauth-bind-rollback-user", Password: "password-hash", Role: 1, Status: 1, Group: "default"} + require.NoError(t, DB.Create(&user).Error) + token, _, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeOAuth, Provider: "custom", Intent: AuthFlowIntentBind, + UserId: user.Id, SessionId: "session-a", ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + actionErr := errors.New("binding commit failed") + + _, err = ConsumeAuthFlowWithAction(token, AuthFlowMatch{ + Purpose: AuthFlowPurposeOAuth, Provider: "custom", Intent: AuthFlowIntentBind, + UserId: user.Id, SessionId: "session-a", + }, func(tx *gorm.DB, _ *AuthFlow) error { + if err := UpdateUserOAuthBindingWithTx(tx, user.Id, 77, "provider-user-77"); err != nil { + return err + } + return actionErr + }) + require.ErrorIs(t, err, actionErr) + + var bindingCount int64 + require.NoError(t, DB.Model(&UserOAuthBinding{}).Count(&bindingCount).Error) + require.Zero(t, bindingCount) + flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "custom", Intent: AuthFlowIntentBind, UserId: user.Id}) + require.NoError(t, err) + require.Nil(t, flow.ConsumedAt) +} + +func TestConsumeAuthFlowWithActionCommitsTogether(t *testing.T) { + setupAuthFlowTestDB(t) + token, _, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeTelegramBind, UserId: 44, SessionId: "session-a", + ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + + match := AuthFlowMatch{Purpose: AuthFlowPurposeTelegramBind, UserId: 44, SessionId: "session-a"} + flow, err := ConsumeAuthFlowWithAction(token, match, func(tx *gorm.DB, _ *AuthFlow) error { + return ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, "assertion-b", time.Now().Add(time.Minute)) + }) + require.NoError(t, err) + require.NotNil(t, flow.ConsumedAt) + + _, err = ConsumeAuthFlowWithAction(token, match, nil) + require.ErrorIs(t, err, ErrAuthFlowConsumed) + require.ErrorIs(t, ClaimExternalAuthAssertion(AuthFlowPurposeTelegramAssertion, "assertion-b", time.Now().Add(time.Minute)), ErrAuthFlowConsumed) +} + +func TestAuthFlowAssertionConflictClauseAvoidsMySQLPrimaryKeyNoop(t *testing.T) { + mysqlClause := authFlowAssertionConflictClause("mysql") + require.False(t, mysqlClause.DoNothing) + require.Len(t, mysqlClause.DoUpdates, 1) + require.Equal(t, "token_hash", mysqlClause.DoUpdates[0].Column.Name) + + conn, err := sql.Open("mysql", "") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + db, err := gorm.Open(gormmysql.New(gormmysql.Config{Conn: conn, SkipInitializeWithVersion: true}), &gorm.Config{ + DryRun: true, DisableAutomaticPing: true, + }) + require.NoError(t, err) + statement := db.ToSQL(func(tx *gorm.DB) *gorm.DB { + return tx.Clauses(authFlowAssertionConflictClause(tx.Dialector.Name())).Create(&AuthFlow{ + TokenHash: "assertion-hash", Purpose: AuthFlowPurposeTelegramAssertion, ExpiresAt: time.Now().Add(time.Minute), + }) + }) + require.Contains(t, statement, "ON DUPLICATE KEY UPDATE `token_hash`=VALUES(`token_hash`)") + require.NotContains(t, strings.ToLower(statement), "update `id`=") + + for _, dialect := range []string{"sqlite", "postgres"} { + require.True(t, authFlowAssertionConflictClause(dialect).DoNothing) + } +} + +func TestMySQLExternalAssertionClaimOwnershipDoesNotTrustRowsAffected(t *testing.T) { + setupAuthFlowTestDB(t) + stored := AuthFlow{ + TokenHash: "stored-assertion-hash", Purpose: AuthFlowPurposeTelegramAssertion, + Payload: "first-claim", ExpiresAt: time.Now().Add(time.Minute), + } + require.NoError(t, DB.Create(&stored).Error) + + claimed, err := ownsExternalAuthAssertionClaim(DB, "mysql", stored.TokenHash, "second-claim", 1) + require.NoError(t, err) + require.False(t, claimed) + + claimed, err = ownsExternalAuthAssertionClaim(DB, "mysql", stored.TokenHash, stored.Payload, 0) + require.NoError(t, err) + require.True(t, claimed) +} + +func TestBindTelegramIdentityRejectsMismatchedSessionWithoutConsumingFlow(t *testing.T) { + setupAuthFlowTestDB(t) + user := User{Id: 42, Username: "telegram-session-user", Password: "password-hash", Role: 1, Status: 1, Group: "default"} + require.NoError(t, DB.Create(&user).Error) + token, _, err := CreateAuthFlow(AuthFlowCreate{ + Purpose: AuthFlowPurposeOAuth, Provider: "telegram", Intent: AuthFlowIntentBind, + UserId: 42, SessionId: "session-a", ExpiresAt: time.Now().Add(time.Minute), + }) + require.NoError(t, err) + + _, err = BindTelegramIdentityWithAuthFlowAndSessionAndAssertion( + 42, "123456", token, "session-b", "telegram-assertion", time.Now().Add(time.Minute), + ) + require.ErrorIs(t, err, ErrAuthFlowInvalid) + + var stored User + require.NoError(t, DB.First(&stored, 42).Error) + require.Empty(t, stored.TelegramId) + flow, err := GetAuthFlow(token, AuthFlowMatch{Purpose: AuthFlowPurposeOAuth, Provider: "telegram", Intent: AuthFlowIntentBind, UserId: 42}) + require.NoError(t, err) + require.Nil(t, flow.ConsumedAt) +} diff --git a/model/authz.go b/model/authz.go new file mode 100644 index 00000000..ceff0dd7 --- /dev/null +++ b/model/authz.go @@ -0,0 +1,52 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// AuthzUserOverride stores one explicit permission decision for a user. +// Absence means that the user's role baseline applies; Allowed=false is an +// explicit deny and therefore must not be represented by a nullable/omitted +// field. +type AuthzUserOverride struct { + ID int64 `gorm:"primaryKey"` + UserID int `gorm:"not null;uniqueIndex:ux_authz_user_permission,priority:1;index"` + Resource string `gorm:"type:varchar(64);not null;uniqueIndex:ux_authz_user_permission,priority:2"` + Action string `gorm:"type:varchar(64);not null;uniqueIndex:ux_authz_user_permission,priority:3"` + Allowed bool `gorm:"not null"` + CreatedAt int64 `gorm:"not null"` + UpdatedAt int64 `gorm:"not null"` +} + +func (AuthzUserOverride) TableName() string { + return "authz_user_overrides" +} + +func (override *AuthzUserOverride) BeforeCreate(_ *gorm.DB) error { + if override.CreatedAt == 0 { + override.CreatedAt = time.Now().Unix() + } + if override.UpdatedAt == 0 { + override.UpdatedAt = override.CreatedAt + } + return nil +} + +func (override *AuthzUserOverride) BeforeUpdate(_ *gorm.DB) error { + override.UpdatedAt = time.Now().Unix() + return nil +} + +// DeleteAuthzUserOverridesTx removes authorization overrides as part of the +// owning user's deletion transaction. +func DeleteAuthzUserOverridesTx(tx *gorm.DB, userID int) error { + if tx == nil { + return gorm.ErrInvalidTransaction + } + if userID <= 0 { + return gorm.ErrInvalidData + } + return tx.Where("user_id = ?", userID).Delete(&AuthzUserOverride{}).Error +} diff --git a/model/cache_invalidation_task.go b/model/cache_invalidation_task.go index d8e0909f..04dd7862 100644 --- a/model/cache_invalidation_task.go +++ b/model/cache_invalidation_task.go @@ -128,6 +128,13 @@ func dispatchStagedCacheInvalidation(task CacheInvalidationTask) { } } +// DispatchStagedCacheInvalidation executes a cache invalidation task after +// its transaction has committed. It is exported for cross-package flows that +// stage invalidation together with an authentication or identity mutation. +func DispatchStagedCacheInvalidation(task CacheInvalidationTask) { + dispatchStagedCacheInvalidation(task) +} + func dispatchStagedCacheInvalidations(tasks []CacheInvalidationTask) { for _, task := range tasks { dispatchStagedCacheInvalidation(task) diff --git a/model/errors.go b/model/errors.go index 63f8e1de..9f9e8999 100644 --- a/model/errors.go +++ b/model/errors.go @@ -14,11 +14,12 @@ var ( // User auth errors var ( - ErrInvalidCredentials = errors.New("invalid credentials") - ErrUserEmptyCredentials = errors.New("empty credentials") - ErrEmailAlreadyTaken = errors.New("email already taken") - ErrEmailNotFound = errors.New("email not found") - ErrEmailAmbiguous = errors.New("email matches multiple users") + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUserEmptyCredentials = errors.New("empty credentials") + ErrEmailAlreadyTaken = errors.New("email already taken") + ErrEmailNotFound = errors.New("email not found") + ErrEmailAmbiguous = errors.New("email matches multiple users") + ErrOAuthIdentityAlreadyTaken = errors.New("OAuth identity is already bound") ) // Token auth errors diff --git a/model/main.go b/model/main.go index e63838af..7e1f07e3 100644 --- a/model/main.go +++ b/model/main.go @@ -364,6 +364,7 @@ func migrateDB() error { &PerfMetricFlushReceipt{}, &SystemTask{}, &SystemInstance{}, + &AuthzUserOverride{}, ) if err != nil { return err diff --git a/model/task_cas_test.go b/model/task_cas_test.go index 9c4e2bcd..8503d3ae 100644 --- a/model/task_cas_test.go +++ b/model/task_cas_test.go @@ -59,6 +59,7 @@ func TestMain(m *testing.M) { &PerfMetric{}, &SystemTask{}, &SystemInstance{}, + &AuthzUserOverride{}, ); err != nil { panic("failed to migrate: " + err.Error()) } @@ -91,6 +92,7 @@ func truncateTables(t *testing.T) { DB.Exec("DELETE FROM perf_metrics") DB.Exec("DELETE FROM system_tasks") DB.Exec("DELETE FROM system_instances") + DB.Exec("DELETE FROM authz_user_overrides") }) } diff --git a/model/user.go b/model/user.go index 6b20fa78..2a117ef9 100644 --- a/model/user.go +++ b/model/user.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" "sync" + "time" "unicode/utf8" "github.com/MAX-API-Next/MAX-API/common" @@ -275,6 +276,12 @@ func withUserOAuthIdentityMutationLock(db *gorm.DB, fn func(db *gorm.DB) error) if db == nil { db = DB } + if db == nil { + return errors.New("database is not initialized") + } + if fn == nil { + return errors.New("OAuth identity mutation callback is required") + } switch { case common.UsingPostgreSQL: return db.Connection(func(conn *gorm.DB) error { @@ -317,6 +324,13 @@ func withUserOAuthIdentityMutationLock(db *gorm.DB, fn func(db *gorm.DB) error) } } +// WithUserOAuthIdentityMutationLock serializes OAuth identity mutations while +// allowing a caller-owned transaction to retain the lock across its complete +// read/write action. +func WithUserOAuthIdentityMutationLock(db *gorm.DB, fn func(db *gorm.DB) error) error { + return withUserOAuthIdentityMutationLock(db, fn) +} + func finishUserOAuthIdentityLock(callbackErr, releaseErr error, released *bool) error { if releaseErr == nil && released != nil { *released = true @@ -1070,6 +1084,16 @@ func (user *User) UpdateFieldsWithTx(tx *gorm.DB, updatePassword bool, fields .. return user.updateWithTx(tx, updatePassword, fields) } +// UpdateFieldsWithTxAndCache applies a user update and stages the durable +// cache invalidation in the same transaction. Callers must dispatch the +// returned task only after the transaction commits. +func (user *User) UpdateFieldsWithTxAndCache(tx *gorm.DB, updatePassword bool, fields ...UserUpdateField) (CacheInvalidationTask, error) { + if err := user.updateWithTx(tx, updatePassword, fields); err != nil { + return CacheInvalidationTask{}, err + } + return stageUserCacheInvalidationTx(tx, user.Id, false) +} + func isAccessTokenOnlyUserUpdate(updatePassword bool, fields []UserUpdateField) bool { return !updatePassword && len(fields) == 1 && fields[0] == UserUpdateFieldAccessToken } @@ -1353,6 +1377,9 @@ func (user *User) deleteWithCacheInvalidation(hardDelete bool) error { if err := tx.Where("user_id = ?", user.Id).Delete(&UserOAuthBinding{}).Error; err != nil { return err } + if err := DeleteAuthzUserOverridesTx(tx, user.Id); err != nil { + return err + } now := common.GetTimestamp() if err := tx.Model(&UserSubscription{}). Where("user_id = ? AND status = ?", user.Id, "active"). @@ -1578,25 +1605,83 @@ func IsOidcIdAlreadyTaken(oidcId string) (bool, error) { return result.RowsAffected > 0, result.Error } +// IsOAuthIdentityTakenWithTx checks a built-in OAuth identity in the caller's +// transaction, excluding the user being updated. The column is selected from +// a closed set so provider input can never become SQL. +func IsOAuthIdentityTakenWithTx(tx *gorm.DB, field UserUpdateField, providerUserID string, excludeUserID int) (bool, error) { + if tx == nil { + return false, errors.New("database is not initialized") + } + providerUserID = strings.TrimSpace(providerUserID) + if providerUserID == "" { + return false, nil + } + column, ok := oauthIdentityColumn(field) + if !ok { + return false, fmt.Errorf("unsupported OAuth identity field: %s", field) + } + query := tx.Unscoped().Model(&User{}).Where(column+" = ?", providerUserID) + if excludeUserID > 0 { + query = query.Where("id <> ?", excludeUserID) + } + var existing User + result := query.Limit(1).Find(&existing) + return result.RowsAffected > 0, result.Error +} + +func oauthIdentityColumn(field UserUpdateField) (string, bool) { + switch field { + case UserUpdateFieldGitHubId: + return "github_id", true + case UserUpdateFieldDiscordId: + return "discord_id", true + case UserUpdateFieldOidcId: + return "oidc_id", true + case UserUpdateFieldLinuxDOId: + return "linux_do_id", true + default: + return "", false + } +} + func IsTelegramIdAlreadyTaken(telegramId string) (bool, error) { result := DB.Unscoped().Where("telegram_id = ?", telegramId).Limit(1).Find(&User{}) return result.RowsAffected > 0, result.Error } func BindTelegramIdentityWithAuthFlow(userID int, telegramID string, state string) (int64, error) { + return BindTelegramIdentityWithAuthFlowAndSession(userID, telegramID, state, "") +} + +// BindTelegramIdentityWithAuthFlowAndSession binds a Telegram identity while +// consuming the matching one-time flow. A non-empty sessionID is enforced for +// newly created flows; an empty value keeps compatibility with legacy flows. +func BindTelegramIdentityWithAuthFlowAndSession(userID int, telegramID string, state string, sessionID string) (int64, error) { + return BindTelegramIdentityWithAuthFlowAndSessionAndAssertion(userID, telegramID, state, sessionID, "", time.Time{}) +} + +// BindTelegramIdentityWithAuthFlowAndSessionAndAssertion binds a Telegram +// identity, consumes the matching flow, and optionally claims the provider +// assertion in one transaction. The optional assertion is used to prevent a +// valid Telegram payload from being replayed with a newly issued state. +func BindTelegramIdentityWithAuthFlowAndSessionAndAssertion(userID int, telegramID string, state string, sessionID string, assertion string, assertionExpiresAt time.Time) (int64, error) { if userID <= 0 || strings.TrimSpace(telegramID) == "" || strings.TrimSpace(state) == "" { return 0, ErrAuthFlowInvalid } + match := AuthFlowMatch{ + Purpose: AuthFlowPurposeOAuth, + Provider: "telegram", + Intent: AuthFlowIntentBind, + UserId: userID, + } + if strings.TrimSpace(sessionID) != "" { + match.SessionId = strings.TrimSpace(sessionID) + } var generation int64 var cacheTask CacheInvalidationTask err := withUserOAuthIdentityMutationLock(DB, func(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { - if _, err := consumeAuthFlowWithDB(tx, state, AuthFlowMatch{ - Purpose: AuthFlowPurposeOAuth, - Provider: "telegram", - Intent: AuthFlowIntentBind, - UserId: userID, - }); err != nil { + if _, err := consumeAuthFlowWithDB(tx, state, match); err != nil { return err } var count int64 @@ -1612,6 +1697,11 @@ func BindTelegramIdentityWithAuthFlow(userID int, telegramID string, state strin if err := ensureUserUpdateMatchedTx(tx, result, userID, ErrUserNotFound); err != nil { return err } + if strings.TrimSpace(assertion) != "" { + if err := ClaimExternalAuthAssertionWithTx(tx, AuthFlowPurposeTelegramAssertion, assertion, assertionExpiresAt); err != nil { + return err + } + } var err error generation, cacheTask, err = bumpUserSessionGenerationTx(tx, userID) return err diff --git a/model/user_delete_test.go b/model/user_delete_test.go index 60431c20..dfde82b3 100644 --- a/model/user_delete_test.go +++ b/model/user_delete_test.go @@ -52,6 +52,8 @@ func TestUserSoftDeleteInvalidatesCredentialsAndCancelsActiveSubscriptions(t *te require.NoError(t, DB.Create(&token).Error) binding := UserOAuthBinding{UserId: user.Id, ProviderId: 9103, ProviderUserId: "soft-delete-provider-user"} require.NoError(t, DB.Create(&binding).Error) + override := AuthzUserOverride{UserID: user.Id, Resource: "channel", Action: "sensitive_write", Allowed: true} + require.NoError(t, DB.Create(&override).Error) subscription := UserSubscription{ Id: 29103, UserId: user.Id, @@ -80,6 +82,9 @@ func TestUserSoftDeleteInvalidatesCredentialsAndCancelsActiveSubscriptions(t *te var bindingCount int64 require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", user.Id).Count(&bindingCount).Error) assert.Zero(t, bindingCount) + var overrideCount int64 + require.NoError(t, DB.Model(&AuthzUserOverride{}).Where("user_id = ?", user.Id).Count(&overrideCount).Error) + assert.Zero(t, overrideCount) var storedSubscription UserSubscription require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) @@ -106,6 +111,8 @@ func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testin require.NoError(t, DB.Create(&token).Error) binding := UserOAuthBinding{UserId: user.Id, ProviderId: 9104, ProviderUserId: "hard-delete-provider-user"} require.NoError(t, DB.Create(&binding).Error) + override := AuthzUserOverride{UserID: user.Id, Resource: "channel", Action: "sensitive_write", Allowed: true} + require.NoError(t, DB.Create(&override).Error) subscription := UserSubscription{ Id: 29104, UserId: user.Id, @@ -132,6 +139,9 @@ func TestUserHardDeleteRemovesCredentialsAndCancelsActiveSubscriptions(t *testin var bindingCount int64 require.NoError(t, DB.Model(&UserOAuthBinding{}).Where("user_id = ?", user.Id).Count(&bindingCount).Error) assert.Zero(t, bindingCount) + var overrideCount int64 + require.NoError(t, DB.Unscoped().Model(&AuthzUserOverride{}).Where("user_id = ?", user.Id).Count(&overrideCount).Error) + assert.Zero(t, overrideCount) var storedSubscription UserSubscription require.NoError(t, DB.First(&storedSubscription, subscription.Id).Error) diff --git a/model/user_oauth_binding.go b/model/user_oauth_binding.go index e94afa49..0f6332a6 100644 --- a/model/user_oauth_binding.go +++ b/model/user_oauth_binding.go @@ -122,9 +122,34 @@ func CreateUserOAuthBindingWithTx(tx *gorm.DB, binding *UserOAuthBinding) error // UpdateUserOAuthBinding updates an existing OAuth binding (e.g., rebind to different OAuth account) func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) error { + if DB == nil { + return errors.New("database is not initialized") + } + return DB.Transaction(func(tx *gorm.DB) error { + return UpdateUserOAuthBindingWithTx(tx, userId, providerId, newProviderUserId) + }) +} + +// UpdateUserOAuthBindingWithTx updates or creates a custom OAuth binding in +// the caller's transaction. Keeping the operation transaction-aware lets +// authentication-flow consumption and the identity mutation commit or roll +// back together. +func UpdateUserOAuthBindingWithTx(tx *gorm.DB, userId, providerId int, newProviderUserId string) error { + if tx == nil { + return errors.New("database is not initialized") + } + if userId <= 0 { + return errors.New("user ID is required") + } + if providerId <= 0 { + return errors.New("provider ID is required") + } + if newProviderUserId == "" { + return errors.New("provider user ID is required") + } // Check if the new provider user ID is already taken by another user var existingBinding UserOAuthBinding - err := DB.Where("provider_id = ? AND provider_user_id = ?", providerId, newProviderUserId).First(&existingBinding).Error + err := tx.Where("provider_id = ? AND provider_user_id = ?", providerId, newProviderUserId).First(&existingBinding).Error if err == nil && existingBinding.UserId != userId { return errors.New("this OAuth account is already bound to another user") } @@ -134,10 +159,10 @@ func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) er // Check if user already has a binding for this provider var binding UserOAuthBinding - err = DB.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error + err = tx.Where("user_id = ? AND provider_id = ?", userId, providerId).First(&binding).Error if errors.Is(err, gorm.ErrRecordNotFound) { // No existing binding, create new one - return CreateUserOAuthBinding(&UserOAuthBinding{ + return CreateUserOAuthBindingWithTx(tx, &UserOAuthBinding{ UserId: userId, ProviderId: providerId, ProviderUserId: newProviderUserId, @@ -148,7 +173,7 @@ func UpdateUserOAuthBinding(userId, providerId int, newProviderUserId string) er } // Update existing binding - return DB.Model(&binding).Update("provider_user_id", newProviderUserId).Error + return tx.Model(&binding).Update("provider_user_id", newProviderUserId).Error } // DeleteUserOAuthBinding deletes an OAuth binding diff --git a/model/user_update_test.go b/model/user_update_test.go index 862c6b74..06dc8065 100644 --- a/model/user_update_test.go +++ b/model/user_update_test.go @@ -282,6 +282,26 @@ func TestIsOidcIdAlreadyTakenHandlesDuplicateLegacyRows(t *testing.T) { require.True(t, taken) } +func TestIsOAuthIdentityTakenWithTxExcludesCurrentUser(t *testing.T) { + setupUserUpdateTestState(t) + primary := User{Id: 136, Username: "oauth-tx-primary", GitHubId: "github-tx-id", AffCode: "oauth-tx-primary-aff", Status: common.UserStatusEnabled} + other := User{Id: 137, Username: "oauth-tx-other", GitHubId: "github-other-id", AffCode: "oauth-tx-other-aff", Status: common.UserStatusEnabled} + require.NoError(t, DB.Create(&primary).Error) + require.NoError(t, DB.Create(&other).Error) + + taken, err := IsOAuthIdentityTakenWithTx(DB, UserUpdateFieldGitHubId, primary.GitHubId, 0) + require.NoError(t, err) + assert.True(t, taken) + taken, err = IsOAuthIdentityTakenWithTx(DB, UserUpdateFieldGitHubId, primary.GitHubId, primary.Id) + require.NoError(t, err) + assert.False(t, taken) + taken, err = IsOAuthIdentityTakenWithTx(DB, UserUpdateFieldGitHubId, other.GitHubId, primary.Id) + require.NoError(t, err) + assert.True(t, taken) + _, err = IsOAuthIdentityTakenWithTx(DB, UserUpdateFieldUsername, "not-an-oauth-field", primary.Id) + require.Error(t, err) +} + func TestOAuthIDTakenChecksReturnDatabaseErrors(t *testing.T) { db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) diff --git a/router/api-router.go b/router/api-router.go index 818696f2..031ebb49 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -4,6 +4,7 @@ import ( "github.com/MAX-API-Next/MAX-API/controller" "github.com/MAX-API-Next/MAX-API/middleware" "github.com/MAX-API-Next/MAX-API/model" + "github.com/MAX-API-Next/MAX-API/service/authz" // Import oauth package to register providers via init() _ "github.com/MAX-API-Next/MAX-API/oauth" @@ -60,6 +61,14 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/oauth/:provider", middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.TryUserAuth(), controller.HandleOAuth) apiRouter.GET("/ratio_config", middleware.CriticalRateLimit(), controller.GetRatioConfig) + // Fine-grained authorization catalog. Route enforcement remains on the + // legacy AdminAuth boundary during the shadow rollout. + authzRoute := apiRouter.Group("/authz") + authzRoute.Use(middleware.AdminAuth()) + { + authzRoute.GET("/catalog", controller.GetPermissionCatalog) + } + apiRouter.POST("/stripe/webhook", anonymousRequestBodyLimit, controller.StripeWebhook) apiRouter.POST("/creem/webhook", anonymousRequestBodyLimit, controller.CreemWebhook) apiRouter.POST("/waffo/webhook", anonymousRequestBodyLimit, controller.WaffoWebhook) @@ -138,7 +147,7 @@ func SetApiRouter(router *gin.Engine) { adminRoute.GET("/", controller.GetAllUsers) adminRoute.GET("/topup", controller.GetAllTopUps) adminRoute.POST("/topup/complete", controller.AdminCompleteTopUp) - adminRoute.GET("/search", controller.SearchUsers) + adminRoute.GET("/search", middleware.AuthzShadow(authz.Permission{Resource: authz.ResourceUser, Action: authz.ActionSearch}), controller.SearchUsers) adminRoute.GET("/:id/oauth/bindings", controller.GetUserOAuthBindingsByAdmin) adminRoute.DELETE("/:id/oauth/bindings/:provider_id", controller.UnbindCustomOAuthByAdmin) adminRoute.DELETE("/:id/bindings/:binding_type", controller.AdminClearUserBinding) @@ -252,9 +261,9 @@ func SetApiRouter(router *gin.Engine) { channelRoute := apiRouter.Group("/channel") channelRoute.Use(middleware.AdminAuth()) { - channelRoute.GET("/", controller.GetAllChannels) + channelRoute.GET("/", middleware.AuthzShadow(authz.ChannelRead), controller.GetAllChannels) channelRoute.GET("/search", controller.SearchChannels) - channelRoute.GET("/models", controller.ChannelListModels) + channelRoute.GET("/models", middleware.AuthzShadow(authz.ChannelRead), controller.ChannelListModels) channelRoute.GET("/models_enabled", controller.EnabledListModels) channelRoute.GET("/:id", controller.GetChannel) channelRoute.POST("/:id/key", middleware.RootAuth(), middleware.CriticalRateLimit(), middleware.DisableCache(), middleware.SecureVerificationRequired(), controller.GetChannelKey) diff --git a/router/api_router_test.go b/router/api_router_test.go index 7a399d7b..0f9f3098 100644 --- a/router/api_router_test.go +++ b/router/api_router_test.go @@ -117,6 +117,19 @@ func TestVerificationMethodsRouteIsRegistered(t *testing.T) { t.Fatal("expected GET /api/verify/methods route to be registered") } +func TestPermissionCatalogRouteIsRegistered(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + SetApiRouter(engine) + + for _, route := range engine.Routes() { + if route.Method == http.MethodGet && route.Path == "/api/authz/catalog" { + return + } + } + t.Fatal("expected GET /api/authz/catalog route to be registered") +} + func TestHealthRoutesAreRegistered(t *testing.T) { gin.SetMode(gin.TestMode) diff --git a/service/authz/authz_test.go b/service/authz/authz_test.go new file mode 100644 index 00000000..37d5653e --- /dev/null +++ b/service/authz/authz_test.go @@ -0,0 +1,95 @@ +package authz + +import ( + "errors" + "testing" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "github.com/glebarez/sqlite" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" +) + +func newAuthzTestDB(t *testing.T) *gorm.DB { + t.Helper() + previous := model.DB + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + sqlDB, err := db.DB() + require.NoError(t, err) + sqlDB.SetMaxOpenConns(1) + require.NoError(t, db.AutoMigrate(&model.AuthzUserOverride{})) + model.DB = db + t.Cleanup(func() { model.DB = previous }) + return db +} + +func TestCatalogAndRoleBaselinesAreStable(t *testing.T) { + catalog := Catalog() + require.NotEmpty(t, catalog) + var channel ResourceDefinition + for _, item := range catalog { + if item.Resource == ResourceChannel { + channel = item + break + } + } + assert.Equal(t, ResourceChannel, channel.Resource) + assert.True(t, roleGrants(common.RoleAdminUser)[ResourceChannel][ActionRead]) + assert.False(t, roleGrants(common.RoleAdminUser)[ResourceChannel][ActionSensitiveWrite]) + assert.True(t, roleGrants(common.RoleRootUser)[ResourceChannel][ActionSensitiveWrite]) + assert.False(t, roleGrants(common.RoleCommonUser)[ResourceChannel][ActionRead]) + roles := Roles() + require.Len(t, roles, 3) + assert.True(t, roles[0].Superuser) +} + +func TestEvaluateUsesUserOverrideAndRootGuard(t *testing.T) { + newAuthzTestDB(t) + assert.True(t, Can(1, common.RoleRootUser, Permission{Resource: ResourceChannel, Action: ActionSecretView})) + assert.False(t, Can(2, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionSecretView})) + require.NoError(t, ReplaceUserOverrides(2, PermissionsMap{ResourceChannel: {ActionSecretView: true, ActionRead: false}})) + assert.True(t, Can(2, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionSecretView})) + assert.False(t, Can(2, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionRead})) + assert.False(t, Can(3, common.RoleCommonUser, Permission{Resource: ResourceChannel, Action: ActionSecretView})) + decision := Evaluate(2, common.RoleAdminUser, Permission{Resource: "unknown", Action: ActionRead}) + assert.ErrorIs(t, decision.Err, ErrUnknownPermission) + assert.False(t, decision.Allowed) +} + +func TestReplaceUserOverridesRejectsUnknownAndPreservesExisting(t *testing.T) { + db := newAuthzTestDB(t) + require.NoError(t, ReplaceUserOverrides(7, PermissionsMap{ResourceChannel: {ActionWrite: false}})) + err := ReplaceUserOverrides(7, PermissionsMap{ResourceChannel: {"unknown": true}}) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrUnknownPermission)) + assert.False(t, Can(7, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionWrite})) + var count int64 + require.NoError(t, db.Model(&model.AuthzUserOverride{}).Where("user_id = ?", 7).Count(&count).Error) + assert.Equal(t, int64(1), count) + require.NoError(t, ClearUserOverrides(7)) + assert.True(t, Can(7, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionWrite})) +} + +func TestEvaluateFailsClosedWhenDatabaseUnavailable(t *testing.T) { + previous := model.DB + model.DB = nil + t.Cleanup(func() { model.DB = previous }) + decision := Evaluate(1, common.RoleAdminUser, Permission{Resource: ResourceChannel, Action: ActionRead}) + assert.False(t, decision.Allowed) + assert.Equal(t, "error", decision.Source) + assert.Error(t, decision.Err) +} + +func TestCapabilitiesFailsClosedWhenDatabaseUnavailable(t *testing.T) { + previous := model.DB + model.DB = nil + t.Cleanup(func() { model.DB = previous }) + + capabilities := Capabilities(1, common.RoleAdminUser) + assert.Empty(t, capabilities) + rootCapabilities := Capabilities(1, common.RoleRootUser) + assert.True(t, rootCapabilities[ResourceChannel][ActionSecretView]) +} diff --git a/service/authz/catalog.go b/service/authz/catalog.go new file mode 100644 index 00000000..5a216f8a --- /dev/null +++ b/service/authz/catalog.go @@ -0,0 +1,164 @@ +package authz + +import "github.com/MAX-API-Next/MAX-API/common" + +// Permission identifies one action on one protected resource. +type Permission struct { + Resource string + Action string +} + +// ActionDefinition is the stable, client-facing description of an action. +// DefaultAdmin controls the legacy administrator baseline. Root is an +// implicit superuser and does not need an explicit grant for any action. +type ActionDefinition struct { + Action string `json:"action"` + LabelKey string `json:"label_key"` + DescriptionKey string `json:"description_key"` + DefaultAdmin bool `json:"default_admin"` +} + +// ResourceDefinition describes the actions exposed by a resource. +type ResourceDefinition struct { + Resource string `json:"resource"` + LabelKey string `json:"label_key"` + Actions []ActionDefinition `json:"actions"` +} + +type RoleDescriptor struct { + Key string `json:"key"` + Name string `json:"name"` + BuiltIn bool `json:"built_in"` + Superuser bool `json:"superuser"` + Grants PermissionsMap `json:"grants"` +} + +type PermissionsMap map[string]map[string]bool + +const ( + ResourceUser = "user" + ResourceChannel = "channel" + ResourceSubscription = "subscription" + ResourceOption = "option" + ResourceCustomOAuth = "custom_oauth" + ResourcePerformance = "performance" + ResourceSmartOps = "smart_ops" + ResourceLog = "log" + ResourceData = "data" + ResourceGroup = "group" + ResourcePrefillGroup = "prefill_group" + ResourceMidjourney = "midjourney" + ResourceTask = "task" + ResourceVendor = "vendor" + ResourceModel = "model" + ResourceDeployment = "deployment" + ResourceRatioSync = "ratio_sync" + ResourceSystem = "system" + ResourceTaskPlugin = "task_plugin" +) + +const ( + ActionRead = "read" + ActionSearch = "search" + ActionOperate = "operate" + ActionWrite = "write" + ActionSensitiveWrite = "sensitive_write" + ActionSecretView = "secret_view" + ActionDelete = "delete" + ActionBind = "bind" +) + +var ( + ChannelRead = Permission{Resource: ResourceChannel, Action: ActionRead} +) + +var registry = []ResourceDefinition{ + resource(ResourceUser, "User Management", action(ActionRead, "Read users", "View user records without changing them.", true), action(ActionSearch, "Search users", "Search users and inspect administrative summaries.", true), action(ActionWrite, "Edit users", "Create, update, enable, disable, or delete non-root users.", true), action(ActionSensitiveWrite, "Edit sensitive user settings", "Change roles, credentials, or other sensitive identity settings.", false)), + resource(ResourceChannel, "Channel Management", action(ActionRead, "Read channels", "View channel lists and details without secrets.", true), action(ActionOperate, "Operate channels", "Test, refresh, enable, or disable channels.", true), action(ActionWrite, "Edit channel routing", "Edit non-sensitive channel routing settings.", true), action(ActionSensitiveWrite, "Edit sensitive channel settings", "Create channels or edit keys and upstream credentials.", false), action(ActionSecretView, "View channel secrets", "View complete channel keys after secure verification.", false)), + resource(ResourceSubscription, "Subscription Management", action(ActionRead, "Read subscriptions", "View plans and user subscriptions.", true), action(ActionOperate, "Operate subscriptions", "Bind, reset, or invalidate subscriptions.", true), action(ActionWrite, "Edit subscription plans", "Create or edit subscription plans.", true)), + resource(ResourceOption, "System Options", action(ActionRead, "Read options", "View non-secret system options.", false), action(ActionWrite, "Edit options", "Change system options and payment configuration.", false)), + resource(ResourceCustomOAuth, "Custom OAuth", action(ActionRead, "Read OAuth providers", "View configured custom OAuth providers.", false), action(ActionWrite, "Edit OAuth providers", "Create, update, or delete custom OAuth providers.", false)), + resource(ResourcePerformance, "Performance", action(ActionRead, "Read performance", "Inspect performance metrics and logs.", false), action(ActionOperate, "Operate performance", "Reset metrics, run GC, or clean log files.", false)), + resource(ResourceSmartOps, "Smart Operations", action(ActionRead, "Read operations", "View operational alerts and reconciliation data.", true), action(ActionWrite, "Review operations", "Review or change operational controls.", false)), + resource(ResourceLog, "Logs", action(ActionRead, "Read logs", "View administrative request and usage logs.", true), action(ActionSearch, "Search logs", "Search administrative logs.", true), action(ActionDelete, "Delete logs", "Delete or clean up logs.", false)), + resource(ResourceData, "Usage Data", action(ActionRead, "Read usage data", "View aggregate usage and quota data.", true)), + resource(ResourceGroup, "Groups", action(ActionRead, "Read groups", "View configured groups.", true), action(ActionWrite, "Edit groups", "Change group configuration.", true)), + resource(ResourcePrefillGroup, "Prefill Groups", action(ActionRead, "Read prefill groups", "View prefill groups.", true), action(ActionWrite, "Edit prefill groups", "Create or edit prefill groups.", true)), + resource(ResourceMidjourney, "Midjourney Tasks", action(ActionRead, "Read Midjourney tasks", "View administrative Midjourney tasks.", true), action(ActionWrite, "Edit Midjourney tasks", "Manage administrative Midjourney tasks.", true)), + resource(ResourceTask, "Tasks", action(ActionRead, "Read tasks", "View administrative tasks.", true)), + resource(ResourceVendor, "Vendors", action(ActionRead, "Read vendors", "View vendor metadata.", true), action(ActionWrite, "Edit vendors", "Create, update, or delete vendor metadata.", true)), + resource(ResourceModel, "Models", action(ActionRead, "Read models", "View model metadata.", true), action(ActionWrite, "Edit models", "Create, update, or delete model metadata.", true), action(ActionOperate, "Operate model sync", "Preview or apply upstream model synchronization.", true)), + resource(ResourceDeployment, "Deployments", action(ActionRead, "Read deployments", "View model deployments and locations.", true), action(ActionWrite, "Edit deployments", "Change model deployment configuration.", true), action(ActionOperate, "Operate deployments", "Test deployment connections.", true)), + resource(ResourceRatioSync, "Ratio Sync", action(ActionRead, "Read ratio sync", "View ratio synchronization targets.", false), action(ActionOperate, "Run ratio sync", "Fetch and apply upstream ratios.", false)), + resource(ResourceSystem, "System", action(ActionRead, "Read system", "View system and instance information.", false), action(ActionOperate, "Operate system", "Run system maintenance operations.", false)), + resource(ResourceTaskPlugin, "Task Plugins", action(ActionBind, "Bind task plugins", "Bind registered task plugins to channels.", false)), +} + +func resource(name, label string, actions ...ActionDefinition) ResourceDefinition { + return ResourceDefinition{Resource: name, LabelKey: label, Actions: actions} +} + +func action(name, label, description string, defaultAdmin bool) ActionDefinition { + return ActionDefinition{Action: name, LabelKey: label, DescriptionKey: description, DefaultAdmin: defaultAdmin} +} + +func Catalog() []ResourceDefinition { + result := make([]ResourceDefinition, 0, len(registry)) + for _, item := range registry { + result = append(result, ResourceDefinition{ + Resource: item.Resource, + LabelKey: item.LabelKey, + Actions: append([]ActionDefinition(nil), item.Actions...), + }) + } + return result +} + +func AllPermissions() []Permission { + result := make([]Permission, 0) + for _, item := range registry { + for _, entry := range item.Actions { + result = append(result, Permission{Resource: item.Resource, Action: entry.Action}) + } + } + return result +} + +func actionDefinition(permission Permission) (ActionDefinition, bool) { + for _, item := range registry { + if item.Resource != permission.Resource { + continue + } + for _, entry := range item.Actions { + if entry.Action == permission.Action { + return entry, true + } + } + } + return ActionDefinition{}, false +} + +func isKnownPermission(permission Permission) bool { + _, ok := actionDefinition(permission) + return ok +} + +func roleGrants(role int) PermissionsMap { + result := make(PermissionsMap, len(registry)) + for _, item := range registry { + actions := make(map[string]bool, len(item.Actions)) + for _, entry := range item.Actions { + actions[entry.Action] = role >= common.RoleRootUser || (role >= common.RoleAdminUser && entry.DefaultAdmin) + } + result[item.Resource] = actions + } + return result +} + +func Roles() []RoleDescriptor { + return []RoleDescriptor{ + {Key: "root", Name: "Root", BuiltIn: true, Superuser: true, Grants: roleGrants(common.RoleRootUser)}, + {Key: "admin", Name: "Admin", BuiltIn: true, Superuser: false, Grants: roleGrants(common.RoleAdminUser)}, + {Key: "user", Name: "User", BuiltIn: true, Superuser: false, Grants: roleGrants(common.RoleCommonUser)}, + } +} diff --git a/service/authz/override.go b/service/authz/override.go new file mode 100644 index 00000000..6ac5158e --- /dev/null +++ b/service/authz/override.go @@ -0,0 +1,84 @@ +package authz + +import ( + "errors" + "fmt" + "sort" + "time" + + "github.com/MAX-API-Next/MAX-API/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func ReplaceUserOverrides(userID int, permissions PermissionsMap) error { + if model.DB == nil { + return errors.New("database is not initialized") + } + if err := model.DB.Transaction(func(tx *gorm.DB) error { + return replaceUserOverridesInTx(tx, userID, permissions) + }); err != nil { + return err + } + InvalidateUser(userID) + return nil +} + +func replaceUserOverridesInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error { + if userID <= 0 { + return errors.New("user id is required") + } + rows := make([]model.AuthzUserOverride, 0) + for resource, actions := range permissions { + for actionName, allowed := range actions { + permission := Permission{Resource: resource, Action: actionName} + if !isKnownPermission(permission) { + return fmt.Errorf("%w: %s.%s", ErrUnknownPermission, resource, actionName) + } + rows = append(rows, model.AuthzUserOverride{ + UserID: userID, + Resource: resource, + Action: actionName, + Allowed: allowed, + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + }) + } + } + sort.Slice(rows, func(i, j int) bool { + if rows[i].Resource == rows[j].Resource { + return rows[i].Action < rows[j].Action + } + return rows[i].Resource < rows[j].Resource + }) + if err := tx.Where("user_id = ?", userID).Delete(&model.AuthzUserOverride{}).Error; err != nil { + return err + } + if len(rows) > 0 { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rows).Error; err != nil { + return err + } + } + return nil +} + +func ReplaceUserOverridesInTx(tx *gorm.DB, userID int, permissions PermissionsMap) error { + if tx == nil { + return errors.New("transaction is required") + } + return replaceUserOverridesInTx(tx, userID, permissions) +} + +func ClearUserOverrides(userID int) error { + if model.DB == nil { + return errors.New("database is not initialized") + } + if userID <= 0 { + return errors.New("user id is required") + } + if err := model.DB.Where("user_id = ?", userID).Delete(&model.AuthzUserOverride{}).Error; err != nil { + return err + } + InvalidateUser(userID) + return nil +} diff --git a/service/authz/resolver.go b/service/authz/resolver.go new file mode 100644 index 00000000..f2779ec2 --- /dev/null +++ b/service/authz/resolver.go @@ -0,0 +1,115 @@ +package authz + +import ( + "errors" + "fmt" + "sync" + + "github.com/MAX-API-Next/MAX-API/common" + "github.com/MAX-API-Next/MAX-API/model" + "gorm.io/gorm" +) + +var ErrUnknownPermission = errors.New("unknown authorization permission") + +// Decision is intentionally richer than a bool so shadow enforcement can +// distinguish a normal deny from a fail-closed database or catalog error. +type Decision struct { + Allowed bool + Source string + Err error +} + +var invalidationMu sync.RWMutex +var invalidationHook func(int) + +// SetInvalidationHookForTest installs a test-only observation seam. The +// initial shadow phase does not cache authorization decisions, so database +// reads remain authoritative and cannot serve stale revocations. +func SetInvalidationHookForTest(hook func(int)) func() { + invalidationMu.Lock() + previous := invalidationHook + invalidationHook = hook + invalidationMu.Unlock() + return func() { + invalidationMu.Lock() + invalidationHook = previous + invalidationMu.Unlock() + } +} + +// InvalidateUser is the cache invalidation seam for future local/distributed +// authorization caches. The first rollout deliberately reads policy rows +// directly, which is safer than introducing a stale permission cache. +func InvalidateUser(userID int) { + if userID <= 0 { + return + } + invalidationMu.RLock() + hook := invalidationHook + invalidationMu.RUnlock() + if hook != nil { + hook(userID) + } +} + +func Can(userID int, systemRole int, permission Permission) bool { + return Evaluate(userID, systemRole, permission).Allowed +} + +func Evaluate(userID int, systemRole int, permission Permission) Decision { + if !isKnownPermission(permission) { + return Decision{Source: "unknown", Err: fmt.Errorf("%w: %s.%s", ErrUnknownPermission, permission.Resource, permission.Action)} + } + if systemRole >= common.RoleRootUser { + return Decision{Allowed: true, Source: "root"} + } + if systemRole < common.RoleAdminUser { + return Decision{Allowed: false, Source: "role"} + } + + entry, _ := actionDefinition(permission) + if model.DB == nil { + return Decision{Source: "error", Err: errors.New("authorization database is not initialized")} + } + + var override model.AuthzUserOverride + result := model.DB.Where("user_id = ? AND resource = ? AND action = ?", userID, permission.Resource, permission.Action).Take(&override) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return Decision{Source: "error", Err: result.Error} + } + if result.RowsAffected > 0 { + if override.Allowed { + return Decision{Allowed: true, Source: "user_override"} + } + return Decision{Allowed: false, Source: "user_override"} + } + return Decision{Allowed: entry.DefaultAdmin, Source: "role"} +} + +func Capabilities(userID int, systemRole int) PermissionsMap { + result := roleGrants(systemRole) + if systemRole >= common.RoleRootUser || systemRole < common.RoleAdminUser || userID <= 0 { + return result + } + if model.DB == nil { + return make(PermissionsMap) + } + + var overrides []model.AuthzUserOverride + if err := model.DB.Where("user_id = ?", userID).Find(&overrides).Error; err != nil { + // A capability response must fail closed when policy storage is not + // readable; callers should not mistake a partial matrix for authority. + return make(PermissionsMap) + } + for _, override := range overrides { + if _, ok := actionDefinition(Permission{Resource: override.Resource, Action: override.Action}); !ok { + continue + } + if _, ok := result[override.Resource]; !ok { + result[override.Resource] = map[string]bool{} + } + result[override.Resource][override.Action] = override.Allowed + } + return result +} diff --git a/web/default/src/stores/auth-store.ts b/web/default/src/stores/auth-store.ts index 84764bab..24522187 100644 --- a/web/default/src/stores/auth-store.ts +++ b/web/default/src/stores/auth-store.ts @@ -21,6 +21,7 @@ import { create } from 'zustand' export type UserPermissions = { sidebar_settings?: boolean sidebar_modules?: Record + admin_permissions?: Record> } export interface AuthUser {