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
4 changes: 2 additions & 2 deletions core/api/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import (
// (sentinel:all). Mirrors the GetApplicationSecret gate.
func requireAnalyticsAccess(c *gin.Context) {
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c),
RequestTokenHasInternalAccess(c),
RequestTokenHasFirstPartyAccess(c) && RequestUserIsAdmin(c),
))
}

Expand Down
40 changes: 31 additions & 9 deletions core/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strings"
"time"

"github.com/gaucho-racing/sentinel/core/authz"
"github.com/gaucho-racing/sentinel/core/config"
"github.com/gaucho-racing/sentinel/core/pkg/logger"
"github.com/gaucho-racing/sentinel/core/service"
Expand Down Expand Up @@ -101,6 +102,7 @@ func InitializeRoutes(router *gin.Engine) {

router.GET("/groups", GetAllGroups)
router.GET("/groups/:id", GetGroupByID)
router.GET("/groups/:id/write-access", CheckGroupWriteAccess)
router.POST("/groups", CreateOrUpdateGroup)
router.DELETE("/groups/:id", DeleteGroup)

Expand Down Expand Up @@ -218,13 +220,7 @@ func RequestTokenExists(c *gin.Context) bool {
}

func RequestTokenHasScope(c *gin.Context, scope string) bool {
scopes := GetRequestTokenScopes(c)
for _, s := range strings.Split(scopes, " ") {
if s == scope {
return true
}
}
return false
return authz.HasScope(GetRequestTokenScopes(c), scope)
}

func RequestTokenHasAudience(c *gin.Context, audience string) bool {
Expand Down Expand Up @@ -267,6 +263,30 @@ func GetRequestTokenClaims(c *gin.Context) map[string]interface{} {
return claims.(map[string]interface{})
}

func RequestTokenHasFirstPartyAccess(c *gin.Context) bool {
return authz.IsFirstPartyUser(
GetRequestTokenScopes(c),
GetRequestTokenAudience(c),
GetRequestTokenClaims(c),
)
}

func RequestTokenHasInternalAccess(c *gin.Context) bool {
return authz.IsInternalServiceAccount(
GetRequestTokenScopes(c),
GetRequestTokenAudience(c),
GetRequestTokenClaims(c),
)
}

func RequestTokenHasResourceScope(c *gin.Context, scope string) bool {
return Any(
RequestTokenHasInternalAccess(c),
RequestTokenHasFirstPartyAccess(c),
RequestTokenHasScope(c, scope),
)
}

// GetRequestTokenEntityID returns the subject (entity_id) of the bearer that
// AuthChecker resolved, or "" if no valid bearer was presented.
func GetRequestTokenEntityID(c *gin.Context) string {
Expand Down Expand Up @@ -324,8 +344,10 @@ func RequestUserIsGroupOwner(c *gin.Context, groupID string) bool {
// with 403 on failure and returns false; otherwise returns true and
// the caller continues.
func requireGroupOwnerOrAdmin(c *gin.Context, groupID string) bool {
if Any(
RequestTokenHasScope(c, "sentinel:all"),
if RequestTokenHasInternalAccess(c) {
return true
}
if RequestTokenHasResourceScope(c, authz.GroupsWriteScope) && Any(
RequestUserIsGroupOwner(c, groupID),
RequestUserIsAdmin(c),
) {
Expand Down
57 changes: 18 additions & 39 deletions core/api/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,15 @@ package api
import (
"net/http"

"github.com/gaucho-racing/sentinel/core/authz"
"github.com/gaucho-racing/sentinel/core/model"
"github.com/gaucho-racing/sentinel/core/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

func GetAllApplications(c *gin.Context) {
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "applications:read"),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope))
applications, err := service.GetAllApplications()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand All @@ -24,11 +21,7 @@ func GetAllApplications(c *gin.Context) {
}

func GetApplicationByID(c *gin.Context) {
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "applications:read"),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope))
id := c.Param("id")
app, err := service.GetApplicationByID(id)
if err != nil {
Expand Down Expand Up @@ -62,10 +55,7 @@ func GetApplicationByClientID(c *gin.Context) {
// internal metadata. The oauth/saml services use it to look up
// the app a token request is targeting, so internal automation
// must work; admins also have full read.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestUserIsAdmin(c),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope))
clientID := c.Param("clientID")
app, err := service.GetApplicationByClientID(clientID)
if err != nil {
Expand Down Expand Up @@ -117,11 +107,7 @@ type createdApplicationResponse struct {
}

func CreateApplication(c *gin.Context) {
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "applications:write"),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsWriteScope))

var req createApplicationRequest
if err := c.ShouldBindJSON(&req); err != nil {
Expand Down Expand Up @@ -196,9 +182,11 @@ func GetApplicationSecret(c *gin.Context) {
return
}
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID),
RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c),
RequestTokenHasInternalAccess(c),
RequestTokenHasResourceScope(c, authz.ApplicationsReadScope) && Any(
RequestTokenHasEntityID(c, app.OwnerID),
RequestUserIsAdmin(c),
),
))
recordAudit(c, model.AuditActionApplicationSecretRevealed, "application", app.ID, model.JSONMap{"name": app.Name})
c.JSON(http.StatusOK, gin.H{"client_secret": app.ClientSecret})
Expand All @@ -225,11 +213,7 @@ func DeleteApplication(c *gin.Context) {
}

func GetApplicationGroups(c *gin.Context) {
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "applications:read"),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope))
id := c.Param("id")
groups, err := service.GetGroupsForApplication(id)
if err != nil {
Expand All @@ -244,7 +228,7 @@ func GetApplicationGroups(c *gin.Context) {
// groups claim and enforce the access gate. Now that oauth carries its own SA
// bearer, the gate is sentinel:all (matches the other internal-only reads).
func GetApplicationGroupsByClientID(c *gin.Context) {
Require(c, RequestTokenHasScope(c, "sentinel:all"))
Require(c, RequestTokenHasInternalAccess(c))
clientID := c.Param("clientID")
app, err := service.GetApplicationByClientID(clientID)
if err != nil {
Expand Down Expand Up @@ -320,11 +304,7 @@ func RemoveApplicationGroup(c *gin.Context) {
}

func GetApplicationRedirectURIs(c *gin.Context) {
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "applications:read"),
))
Require(c, RequestTokenHasResourceScope(c, authz.ApplicationsReadScope))
id := c.Param("id")
uris, err := service.GetRedirectURIsForApplication(id)
if err != nil {
Expand Down Expand Up @@ -392,10 +372,9 @@ func RemoveApplicationRedirectURI(c *gin.Context) {
// owner OR an Admins-group member, or a third-party token with
// applications:write granted by the owner.
func ApplicationWriteAuthorized(c *gin.Context, app model.Application) bool {
return Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasAudience(c, "sentinel") && RequestTokenHasEntityID(c, app.OwnerID),
RequestTokenHasAudience(c, "sentinel") && RequestUserIsAdmin(c),
RequestTokenHasScope(c, "applications:write") && RequestTokenHasEntityID(c, app.OwnerID),
)
return RequestTokenHasInternalAccess(c) ||
RequestTokenHasResourceScope(c, authz.ApplicationsWriteScope) && Any(
RequestTokenHasEntityID(c, app.OwnerID),
RequestUserIsAdmin(c),
)
}
5 changes: 5 additions & 0 deletions core/api/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/http"

"github.com/gaucho-racing/sentinel/core/authz"
"github.com/gaucho-racing/sentinel/core/config"
"github.com/gaucho-racing/sentinel/core/jobs"
"github.com/gaucho-racing/sentinel/core/pkg/logger"
Expand Down Expand Up @@ -78,6 +79,10 @@ func BootstrapToken(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if sa.Scope != authz.SentinelInternalScope {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "internal service account scope is not configured"})
return
}

if sa.SignedToken == "" {
// Best-effort mint to recover from a partial-seed state. If
Expand Down
3 changes: 2 additions & 1 deletion core/api/conditional_binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import (
"errors"
"net/http"

"github.com/gaucho-racing/sentinel/core/authz"
"github.com/gaucho-racing/sentinel/core/model"
"github.com/gaucho-racing/sentinel/core/service"
"github.com/gin-gonic/gin"
)

func GetGroupConditionalBindings(c *gin.Context) {
Require(c, RequestTokenExists(c))
Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope))

id := c.Param("id")
bindings, err := service.GetConditionalBindingsForGroup(id)
Expand Down
44 changes: 14 additions & 30 deletions core/api/entity.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,15 @@ package api
import (
"net/http"

"github.com/gaucho-racing/sentinel/core/authz"
"github.com/gaucho-racing/sentinel/core/model"
"github.com/gaucho-racing/sentinel/core/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)

func GetMe(c *gin.Context) {
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "user:read"),
))
Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope))
id := GetRequestTokenEntityID(c)

entity, err := service.GetEntityByID(id)
Expand All @@ -30,11 +28,7 @@ func GetMe(c *gin.Context) {

func GetEntity(c *gin.Context) {
id := c.Param("id")
Require(c, Any(
RequestTokenHasAudience(c, "sentinel"),
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasScope(c, "user:read") && RequestTokenHasEntityID(c, id),
))
Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope))

entity, err := service.GetEntityByID(id)
if err != nil {
Expand All @@ -53,11 +47,7 @@ func GetEntityByID(c *gin.Context) {
// Entity rows carry PII (email-auth, phone-auth, linked external
// identities, user profile). Self can read their own; admin and
// internal automation override.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasEntityID(c, entityID),
RequestUserIsAdmin(c),
))
Require(c, RequestTokenHasResourceScope(c, authz.UserReadScope))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Retain subject checks on entity reads

For a third-party token with user:read, this gate now permits requesting any entityID, rather than only the token subject. service.GetEntityByID populates email and phone authentication records plus EntityExternalAuth rows whose JSON includes provider access_token and refresh_token fields, so a known entity ID—obtainable from the newly exposed /users collection—can disclose another user's PII and stored provider credentials. Preserve the previous self/admin/internal restriction or expose a sanitized profile DTO for scoped reads.

Useful? React with 👍 / 👎.

entity, err := service.GetEntityByID(entityID)
if err != nil {
if err == gorm.ErrRecordNotFound {
Expand All @@ -75,11 +65,7 @@ func GetEntityGroups(c *gin.Context) {
// Group membership is an authorization signal — leaking another
// user's groups would tell an attacker who has admin-equivalent
// access. Self / admin / internal only.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasEntityID(c, entityID),
RequestUserIsAdmin(c),
))
Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope))
groups, err := service.GetGroupsForEntity(entityID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
Expand All @@ -96,11 +82,7 @@ func GetEntityMemberships(c *gin.Context) {
// Raw GroupMember rows (with source labels) are used by integration
// services to diff their own writes — same self/admin/internal
// trust level as GetEntityGroups.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasEntityID(c, entityID),
RequestUserIsAdmin(c),
))
Require(c, RequestTokenHasResourceScope(c, authz.GroupsReadScope))
source := c.Query("source")
memberships, err := service.GetMembershipsForEntity(entityID, source)
if err != nil {
Expand All @@ -115,7 +97,7 @@ func GetEntityByExternalAuth(c *gin.Context) {
// map to?" — leaks the user/Discord identity pairing. Reserved for
// internal automation; the oauth-discord-login flow is the
// canonical caller.
Require(c, RequestTokenHasScope(c, "sentinel:all"))
Require(c, RequestTokenHasInternalAccess(c))
provider := c.Param("provider")
externalID := c.Param("externalID")
entity, err := service.GetEntityByExternalAuth(provider, externalID)
Expand All @@ -133,7 +115,7 @@ func GetEntityByExternalAuth(c *gin.Context) {
func ListExternalAuthsByProvider(c *gin.Context) {
// Enumeration of every onboarded user for a provider — used by
// the discord sync's full sweep. Internal callers only.
Require(c, RequestTokenHasScope(c, "sentinel:all"))
Require(c, RequestTokenHasInternalAccess(c))
provider := c.Param("provider")
auths, err := service.ListExternalAuthsByProvider(provider)
if err != nil {
Expand All @@ -148,7 +130,7 @@ func CreateEntityLogin(c *gin.Context) {
// 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"))
Require(c, RequestTokenHasInternalAccess(c))
var login model.EntityLogin
if err := c.ShouldBindJSON(&login); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
Expand All @@ -167,9 +149,11 @@ func GetEntityLogins(c *gin.Context) {
// Login history is an audit-grade signal. Self / admin / internal
// only.
Require(c, Any(
RequestTokenHasScope(c, "sentinel:all"),
RequestTokenHasEntityID(c, entityID),
RequestUserIsAdmin(c),
RequestTokenHasInternalAccess(c),
RequestTokenHasResourceScope(c, authz.UserReadScope) && Any(
RequestTokenHasEntityID(c, entityID),
RequestUserIsAdmin(c),
),
))
logins, err := service.GetEntityLogins(service.EntityLoginsFilter{
EntityID: entityID,
Expand Down
Loading
Loading