diff --git a/core/api/entity.go b/core/api/entity.go index 716309f..ddb3dbf 100644 --- a/core/api/entity.go +++ b/core/api/entity.go @@ -112,6 +112,11 @@ func ListExternalAuthsByProvider(c *gin.Context) { } func CreateEntityLogin(c *gin.Context) { + // Login rows are the audit log of token issuance. Writing into + // them is reserved for the oauth service (which records each + // session it mints); admins/users shouldn't be backdating their + // own entries. + Require(c, RequestTokenHasScope(c, "sentinel:all")) var login model.EntityLogin if err := c.ShouldBindJSON(&login); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) diff --git a/core/api/onboarding.go b/core/api/onboarding.go index ebf8139..cf046f6 100644 --- a/core/api/onboarding.go +++ b/core/api/onboarding.go @@ -14,6 +14,11 @@ type createEntityRequest struct { } func CreateEntity(c *gin.Context) { + // Entities are the root identity row that everything else hangs + // off (users, service accounts, group memberships). Creation is + // reserved for internal automation — the discord onboarding flow + // is the canonical caller and now carries sentinel:all via its SA. + Require(c, RequestTokenHasScope(c, "sentinel:all")) var req createEntityRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) @@ -37,6 +42,12 @@ type createEmailAuthRequest struct { // (password reset, email change). Picks the service-layer function based // on whether a row already exists. func CreateEntityEmailAuth(c *gin.Context) { + // This endpoint upserts an entity's email + password — the entire + // account-takeover primitive in one call. Reserved for internal + // callers (discord onboarding mints the initial email auth; future + // password-reset flows would go through a separate token-mediated + // path before hitting this handler). + Require(c, RequestTokenHasScope(c, "sentinel:all")) entityID := c.Param("entityID") var req createEmailAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -76,6 +87,9 @@ type createPhoneAuthRequest struct { } func CreateEntityPhoneAuth(c *gin.Context) { + // Same trust level as the other entity-auth writers: internal + // onboarding only. + Require(c, RequestTokenHasScope(c, "sentinel:all")) entityID := c.Param("entityID") var req createPhoneAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -96,6 +110,10 @@ type createExternalAuthRequest struct { } func CreateEntityExternalAuth(c *gin.Context) { + // Linking an external identity (DISCORD, GITHUB, etc.) to an + // entity is account-takeover-adjacent — anyone able to write this + // row can claim any entity. Internal callers only. + Require(c, RequestTokenHasScope(c, "sentinel:all")) entityID := c.Param("entityID") var req createExternalAuthRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -123,6 +141,10 @@ type updateExternalAuthMetadataRequest struct { // successful provider sign-in so the email / username / avatar that came // back from the provider stays current. func UpdateEntityExternalAuthMetadata(c *gin.Context) { + // Called by login handlers on every successful provider sign-in + // (oauth-discord-login refreshes the cached email/username/avatar + // after a successful Discord exchange). Internal callers only. + Require(c, RequestTokenHasScope(c, "sentinel:all")) entityID := c.Param("entityID") provider := c.Param("provider") var req updateExternalAuthMetadataRequest diff --git a/core/api/user.go b/core/api/user.go index 7007161..8b74acf 100644 --- a/core/api/user.go +++ b/core/api/user.go @@ -63,6 +63,27 @@ func CreateOrUpdateUser(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + + // Create vs update have different trust requirements. Creates are + // reserved for internal onboarding (sentinel:all) or admins — + // arbitrary user creation through this endpoint would be an + // account-fabrication primitive. Updates allow the user to edit + // their own profile, plus the usual admin/internal overrides. + if existing.ID == "" { + Require(c, Any( + RequestTokenHasScope(c, "sentinel:all"), + RequestUserIsAdmin(c), + )) + } else { + Require(c, Any( + RequestTokenHasScope(c, "sentinel:all"), + RequestTokenHasUserID(c, existing.ID), + RequestTokenHasEntityID(c, existing.EntityID), + RequestTokenHasScope(c, "user:write") && RequestTokenHasUserID(c, existing.ID), + RequestUserIsAdmin(c), + )) + } + if existing.ID != "" { user, err = service.UpdateUser(user) } else { @@ -76,6 +97,13 @@ func CreateOrUpdateUser(c *gin.Context) { } func DeleteUser(c *gin.Context) { + // Deleting a user is admin-only — no self-delete path through + // this endpoint (a separate account-closure flow would handle + // that with the right cleanup). + Require(c, Any( + RequestTokenHasScope(c, "sentinel:all"), + RequestUserIsAdmin(c), + )) id := c.Param("id") if err := service.DeleteUser(id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})