@@ -1,189 +0,0 @@
|
||||
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
// LEGACY ACCESS MANAGEMENT SERVICE - DEPRECATED
|
||||
//
|
||||
// This service implements the legacy authorization model that uses the Permissions
|
||||
// map from permissions.go to check if a principal can perform an action.
|
||||
//
|
||||
// It is being replaced by Authorizer which uses a policy-based evaluation system.
|
||||
// During migration, this service is still used for:
|
||||
// - API key authorization (intersection semantics between user and API key roles)
|
||||
// - Fallback for any unmapped legacy actions
|
||||
//
|
||||
// Once all actions are migrated and API key authorization is implemented in the
|
||||
// new system, this service will be removed.
|
||||
//
|
||||
// Deprecated: Use Authorizer.Authorize() instead for new code.
|
||||
package iam
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
type (
|
||||
AccessManagementService struct {
|
||||
*Service
|
||||
}
|
||||
)
|
||||
|
||||
func NewAccessManagementService(svc *Service) *AccessManagementService {
|
||||
return &AccessManagementService{Service: svc}
|
||||
}
|
||||
|
||||
// Authorize implements Model 2 authorization:
|
||||
// - principalID is the actor (Identity now; later service accounts)
|
||||
// - credentialID is an optional credential (PersonalAPIKey now)
|
||||
// - intersection semantics: actor must be allowed AND credential (if present) must be allowed.
|
||||
//
|
||||
// Entity scope:
|
||||
// - Global/self-owned entities (Identity/Session/PersonalAPIKey) are authorized via ownership checks only (no global admin).
|
||||
// - Organization-scoped entities are authorized via membership lookups that derive organization_id from entityID.
|
||||
func (s *AccessManagementService) Authorize(ctx context.Context, principalID gid.GID, credentialID *gid.GID, entityID gid.GID, action Action) error {
|
||||
requiredRoles := GetPermissionsForAction(entityID.EntityType(), action)
|
||||
if requiredRoles == nil {
|
||||
entityModel, _ := coredata.EntityModel(entityID.EntityType())
|
||||
return NewNoPermissionsDefinedError(entityModel, action)
|
||||
}
|
||||
|
||||
switch principalID.EntityType() {
|
||||
case coredata.IdentityEntityType:
|
||||
// ok
|
||||
default:
|
||||
return NewUnsupportedPrincipalTypeError(principalID.EntityType())
|
||||
}
|
||||
|
||||
return s.pg.WithConn(ctx, func(conn pg.Conn) error {
|
||||
// Global/self-owned path
|
||||
switch entityID.EntityType() {
|
||||
case coredata.IdentityEntityType:
|
||||
if entityID != principalID {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
return nil
|
||||
|
||||
case coredata.SessionEntityType:
|
||||
sess := &coredata.Session{}
|
||||
if err := sess.LoadByID(ctx, conn, entityID); err != nil {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
if sess.IdentityID != principalID {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
return nil
|
||||
|
||||
case coredata.PersonalAPIKeyEntityType:
|
||||
key := &coredata.PersonalAPIKey{}
|
||||
if err := key.LoadByID(ctx, conn, entityID); err != nil {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
if key.IdentityID != principalID {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Organization-scoped path (derive org via joins)
|
||||
scope := coredata.NewScope(entityID.TenantID())
|
||||
|
||||
actorRoleName, err := s.loadIdentityRoleForEntity(ctx, conn, scope, principalID, entityID)
|
||||
if err != nil || !requiredRoleNamesContain(actorRoleName, requiredRoles) {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
|
||||
// Optional credential restriction (intersection)
|
||||
if credentialID != nil {
|
||||
switch credentialID.EntityType() {
|
||||
case coredata.PersonalAPIKeyEntityType:
|
||||
// Defensive check: credential must belong to actor
|
||||
apiKey := &coredata.PersonalAPIKey{}
|
||||
if err := apiKey.LoadByID(ctx, conn, *credentialID); err != nil {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
if apiKey.IdentityID != principalID {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
|
||||
keyRoleName, err := s.loadAPIKeyRoleForEntity(ctx, conn, scope, *credentialID, entityID)
|
||||
if err != nil || !requiredRoleNamesContain(keyRoleName, requiredRoles) {
|
||||
return NewInsufficientPermissionsError(principalID, entityID, action)
|
||||
}
|
||||
default:
|
||||
return NewUnsupportedPrincipalTypeError(credentialID.EntityType())
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AccessManagementService) loadIdentityRoleForEntity(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope coredata.Scoper,
|
||||
identityID gid.GID,
|
||||
entityID gid.GID,
|
||||
) (Role, error) {
|
||||
var m coredata.Membership
|
||||
if err := m.LoadRoleByIdentityAndEntityID(ctx, conn, scope, identityID, entityID); err != nil {
|
||||
// Do not leak existence details
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return "", err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return Role(m.Role.String()), nil
|
||||
}
|
||||
|
||||
func (s *AccessManagementService) loadAPIKeyRoleForEntity(
|
||||
ctx context.Context,
|
||||
conn pg.Conn,
|
||||
scope coredata.Scoper,
|
||||
apiKeyID gid.GID,
|
||||
entityID gid.GID,
|
||||
) (Role, error) {
|
||||
// Load the API key to get the identity
|
||||
apiKey := &coredata.PersonalAPIKey{}
|
||||
if err := apiKey.LoadByID(ctx, conn, apiKeyID); err != nil {
|
||||
return "", fmt.Errorf("cannot load api key: %w", err)
|
||||
}
|
||||
|
||||
// Use the Identity's membership role for authorization
|
||||
var m coredata.Membership
|
||||
if err := m.LoadRoleByIdentityAndEntityID(ctx, conn, scope, apiKey.IdentityID, entityID); err != nil {
|
||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||
return "", err
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return Role(m.Role.String()), nil
|
||||
}
|
||||
|
||||
// requiredRoleNamesContain is a temporary evaluator for the current in-code permissions registry
|
||||
// (`Permissions` in `permissions.go`). In the future this becomes policy-document evaluation
|
||||
// where the role name resolves to policy statements.
|
||||
func requiredRoleNamesContain(roleName Role, required []Role) bool {
|
||||
for _, r := range required {
|
||||
if r == roleName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -17,9 +17,7 @@ package iam
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
@@ -27,21 +25,12 @@ import (
|
||||
"go.probo.inc/probo/pkg/iam/policy"
|
||||
)
|
||||
|
||||
// Authorizer handles authorization using the policy engine.
|
||||
type Authorizer struct {
|
||||
pg *pg.Client
|
||||
evaluator *policy.Evaluator
|
||||
policySet *PolicySet
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new authorizer.
|
||||
// Services register their policies by calling RegisterPolicySet.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// authorizer := iam.NewAuthorizer(pgClient)
|
||||
// authorizer.RegisterPolicySet(iam.IAMPolicySet())
|
||||
// authorizer.RegisterPolicySet(probo.ProboPolicySet())
|
||||
func NewAuthorizer(pgClient *pg.Client) *Authorizer {
|
||||
return &Authorizer{
|
||||
pg: pgClient,
|
||||
@@ -50,87 +39,24 @@ func NewAuthorizer(pgClient *pg.Client) *Authorizer {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterPolicySet merges policies from another service into this authorizer.
|
||||
// Services call this method to register their policies.
|
||||
func (a *Authorizer) RegisterPolicySet(policySet *PolicySet) {
|
||||
a.policySet.Merge(policySet)
|
||||
}
|
||||
|
||||
// AuthorizeParams contains all parameters for an authorization check.
|
||||
type AuthorizeParams struct {
|
||||
// Principal is the user requesting access.
|
||||
Principal gid.GID
|
||||
|
||||
// Resource is the target resource.
|
||||
Resource gid.GID
|
||||
|
||||
// Action is the operation being performed (e.g., "iam:organization:get").
|
||||
Action string
|
||||
|
||||
// ResourceAttributes provides additional context for condition evaluation.
|
||||
// Keys like "user_id", "owner_id" are used for self-management checks.
|
||||
Principal gid.GID
|
||||
Resource gid.GID
|
||||
Action string
|
||||
ResourceAttributes map[string]string
|
||||
}
|
||||
|
||||
func (a *Authorizer) GetPermissionsForMembership(ctx context.Context, identityID gid.GID, membershipID gid.GID) (map[string]map[Action]bool, error) {
|
||||
var (
|
||||
scope = coredata.NewScopeFromObjectID(membershipID)
|
||||
membership = &coredata.Membership{}
|
||||
)
|
||||
|
||||
err := a.pg.WithConn(
|
||||
ctx,
|
||||
func(conn pg.Conn) error {
|
||||
err := membership.LoadByID(ctx, conn, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewMembershipNotFoundError(membershipID)
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
permissions := make(map[string]map[Action]bool)
|
||||
|
||||
for entityType, actions := range Permissions {
|
||||
entityTypeName, ok := coredata.EntityModel(entityType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if permissions[entityTypeName] == nil {
|
||||
permissions[entityTypeName] = make(map[Action]bool)
|
||||
}
|
||||
|
||||
for action, allowedRoles := range actions {
|
||||
if slices.Contains(allowedRoles, Role(membership.Role)) {
|
||||
permissions[entityTypeName][action] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// Authorize checks if the principal can perform the action on the resource.
|
||||
// It combines self-management policies with role-based policies.
|
||||
func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) error {
|
||||
// Validate principal type
|
||||
if params.Principal.EntityType() != coredata.IdentityEntityType {
|
||||
return NewUnsupportedPrincipalTypeError(params.Principal.EntityType())
|
||||
}
|
||||
|
||||
// Build policies to evaluate
|
||||
policies := a.buildPolicies(ctx, params)
|
||||
|
||||
// Build condition context
|
||||
conditionCtx := policy.ConditionContext{
|
||||
Principal: map[string]string{
|
||||
"id": params.Principal.String(),
|
||||
@@ -142,7 +68,6 @@ func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) erro
|
||||
|
||||
maps.Copy(conditionCtx.Resource, params.ResourceAttributes)
|
||||
|
||||
// Evaluate
|
||||
req := policy.AuthorizationRequest{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
@@ -160,7 +85,6 @@ func (a *Authorizer) Authorize(ctx context.Context, params AuthorizeParams) erro
|
||||
return NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action)
|
||||
}
|
||||
|
||||
// No match = implicit deny
|
||||
return NewInsufficientPermissionsError(params.Principal, params.Resource, params.Action)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,8 @@
|
||||
|
||||
package iam
|
||||
|
||||
// Action represents a permission action string.
|
||||
// Used for backward compatibility with the existing permission system.
|
||||
type Action = string
|
||||
|
||||
// IAM Service Actions
|
||||
const (
|
||||
// Organization actions
|
||||
ActionIAMOrganizationCreate = "iam:organization:create"
|
||||
|
||||
@@ -36,14 +36,13 @@ type (
|
||||
privateKey *rsa.PrivateKey
|
||||
logger *log.Logger
|
||||
|
||||
AccountService *AccountService
|
||||
OrganizationService *OrganizationService
|
||||
SessionService *SessionService
|
||||
AuthService *AuthService
|
||||
SAMLService *saml.Service
|
||||
APIKeyService *APIKeyService
|
||||
LegacyAccessManagementService *AccessManagementService
|
||||
Authorizer *Authorizer
|
||||
AccountService *AccountService
|
||||
OrganizationService *OrganizationService
|
||||
SessionService *SessionService
|
||||
AuthService *AuthService
|
||||
SAMLService *saml.Service
|
||||
APIKeyService *APIKeyService
|
||||
Authorizer *Authorizer
|
||||
|
||||
samlDomainVerifier *SAMLDomainVerifier
|
||||
}
|
||||
@@ -110,7 +109,6 @@ func NewService(
|
||||
svc.SessionService = NewSessionService(svc)
|
||||
svc.AuthService = NewAuthService(svc)
|
||||
svc.APIKeyService = NewAPIKeyService(svc)
|
||||
svc.LegacyAccessManagementService = NewAccessManagementService(svc)
|
||||
|
||||
svc.Authorizer = NewAuthorizer(pgClient)
|
||||
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
|
||||
|
||||
Reference in New Issue
Block a user