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
5 changes: 5 additions & 0 deletions core/api/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()})
Expand Down
22 changes: 22 additions & 0 deletions core/api/onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()})
Expand All @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions core/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()})
Expand Down
Loading