@@ -49,11 +49,6 @@ type (
|
||||
InvitationToken string
|
||||
}
|
||||
|
||||
LoadOrCreateIdentityRequest struct {
|
||||
Email mail.Addr
|
||||
FullName string
|
||||
}
|
||||
|
||||
CreateIdentityWithPasswordRequest struct {
|
||||
Email mail.Addr
|
||||
Password string
|
||||
@@ -115,14 +110,6 @@ func (req ChangePasswordRequest) Validate() error {
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (req LoadOrCreateIdentityRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
v.Check(req.FullName, "fullName", validator.NotEmpty(), validator.MinLen(1), validator.MaxLen(255))
|
||||
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (req CreateIdentityWithPasswordRequest) Validate() error {
|
||||
v := validator.New()
|
||||
|
||||
|
||||
@@ -313,19 +313,6 @@ func (e ErrInvalidCredentials) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
type ErrInvitationNotDeleted struct {
|
||||
InvitationID gid.GID
|
||||
Status string
|
||||
}
|
||||
|
||||
func NewInvitationNotDeletedError(invitationID gid.GID, status string) error {
|
||||
return &ErrInvitationNotDeleted{InvitationID: invitationID, Status: status}
|
||||
}
|
||||
|
||||
func (e ErrInvitationNotDeleted) Error() string {
|
||||
return fmt.Sprintf("cannot delete invitation %q in %q status", e.InvitationID, e.Status)
|
||||
}
|
||||
|
||||
type ErrPasswordAuthenticationRequired struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
@@ -1,126 +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.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Action represents a permission action in the format "service:resource:operation"
|
||||
// Examples: "iam:identity:get", "documents:document:write", "risks:risk:delete"
|
||||
type Action string
|
||||
|
||||
// ActionDefinition provides metadata about an action for documentation and validation.
|
||||
type ActionDefinition struct {
|
||||
Action Action
|
||||
Service string // e.g., "iam", "documents", "risks"
|
||||
Resource string // e.g., "identity", "document", "risk"
|
||||
Operation string // e.g., "get", "list", "create", "update", "delete"
|
||||
Description string
|
||||
}
|
||||
|
||||
// ActionRegistry holds all registered actions and provides lookup/validation.
|
||||
type ActionRegistry struct {
|
||||
actions map[Action]ActionDefinition
|
||||
}
|
||||
|
||||
// NewActionRegistry creates a new empty action registry.
|
||||
func NewActionRegistry() *ActionRegistry {
|
||||
return &ActionRegistry{
|
||||
actions: make(map[Action]ActionDefinition),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds an action definition to the registry.
|
||||
// Returns an error if the action is already registered.
|
||||
func (r *ActionRegistry) Register(def ActionDefinition) error {
|
||||
if _, exists := r.actions[def.Action]; exists {
|
||||
return fmt.Errorf("action %q already registered", def.Action)
|
||||
}
|
||||
|
||||
// Validate action format
|
||||
if err := validateActionFormat(def.Action); err != nil {
|
||||
return fmt.Errorf("invalid action format: %w", err)
|
||||
}
|
||||
|
||||
r.actions[def.Action] = def
|
||||
return nil
|
||||
}
|
||||
|
||||
// MustRegister is like Register but panics on error.
|
||||
// Useful for setting up registries in application startup.
|
||||
func (r *ActionRegistry) MustRegister(def ActionDefinition) {
|
||||
if err := r.Register(def); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns the definition for an action, or false if not found.
|
||||
func (r *ActionRegistry) Get(action Action) (ActionDefinition, bool) {
|
||||
def, ok := r.actions[action]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
// Exists checks if an action is registered.
|
||||
func (r *ActionRegistry) Exists(action Action) bool {
|
||||
_, ok := r.actions[action]
|
||||
return ok
|
||||
}
|
||||
|
||||
// All returns all registered action definitions.
|
||||
func (r *ActionRegistry) All() []ActionDefinition {
|
||||
result := make([]ActionDefinition, 0, len(r.actions))
|
||||
for _, def := range r.actions {
|
||||
result = append(result, def)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ByService returns all actions for a given service.
|
||||
func (r *ActionRegistry) ByService(service string) []ActionDefinition {
|
||||
var result []ActionDefinition
|
||||
for _, def := range r.actions {
|
||||
if def.Service == service {
|
||||
result = append(result, def)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// validateActionFormat ensures action follows "service:resource:operation" format.
|
||||
func validateActionFormat(action Action) error {
|
||||
parts := strings.Split(string(action), ":")
|
||||
if len(parts) != 3 {
|
||||
return fmt.Errorf("action must have format 'service:resource:operation', got %q", action)
|
||||
}
|
||||
|
||||
for i, part := range parts {
|
||||
if part == "" {
|
||||
return fmt.Errorf("action part %d is empty in %q", i, action)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseAction extracts service, resource, and operation from an action string.
|
||||
func ParseAction(action Action) (service, resource, operation string, err error) {
|
||||
parts := strings.Split(string(action), ":")
|
||||
if len(parts) != 3 {
|
||||
return "", "", "", fmt.Errorf("invalid action format: %q", action)
|
||||
}
|
||||
return parts[0], parts[1], parts[2], nil
|
||||
}
|
||||
@@ -1,219 +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.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestActionRegistry_Register(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
def ActionDefinition
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid action",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid format - missing parts",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - empty part",
|
||||
def: ActionDefinition{
|
||||
Action: "iam::get",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid format - too many parts",
|
||||
def: ActionDefinition{
|
||||
Action: "iam:identity:get:extra",
|
||||
Service: "iam",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
err := r.Register(tt.def)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Register() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_DuplicateRegistration(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
def := ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
}
|
||||
|
||||
// First registration should succeed
|
||||
if err := r.Register(def); err != nil {
|
||||
t.Fatalf("First registration failed: %v", err)
|
||||
}
|
||||
|
||||
// Second registration should fail
|
||||
if err := r.Register(def); err == nil {
|
||||
t.Error("Expected error for duplicate registration, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_Get(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
def := ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity",
|
||||
}
|
||||
r.MustRegister(def)
|
||||
|
||||
// Get existing action
|
||||
got, ok := r.Get("iam:identity:get")
|
||||
if !ok {
|
||||
t.Error("Expected to find action")
|
||||
}
|
||||
if got.Action != def.Action {
|
||||
t.Errorf("Got action %v, want %v", got.Action, def.Action)
|
||||
}
|
||||
|
||||
// Get non-existing action
|
||||
_, ok = r.Get("iam:identity:delete")
|
||||
if ok {
|
||||
t.Error("Expected not to find action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_Exists(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.MustRegister(ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
})
|
||||
|
||||
if !r.Exists("iam:identity:get") {
|
||||
t.Error("Expected action to exist")
|
||||
}
|
||||
|
||||
if r.Exists("iam:identity:delete") {
|
||||
t.Error("Expected action not to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionRegistry_ByService(t *testing.T) {
|
||||
r := NewActionRegistry()
|
||||
|
||||
r.MustRegister(ActionDefinition{Action: "iam:identity:get", Service: "iam", Resource: "identity", Operation: "get"})
|
||||
r.MustRegister(ActionDefinition{Action: "iam:identity:update", Service: "iam", Resource: "identity", Operation: "update"})
|
||||
r.MustRegister(ActionDefinition{Action: "documents:document:read", Service: "documents", Resource: "document", Operation: "read"})
|
||||
|
||||
iamActions := r.ByService("iam")
|
||||
if len(iamActions) != 2 {
|
||||
t.Errorf("Expected 2 IAM actions, got %d", len(iamActions))
|
||||
}
|
||||
|
||||
docActions := r.ByService("documents")
|
||||
if len(docActions) != 1 {
|
||||
t.Errorf("Expected 1 documents action, got %d", len(docActions))
|
||||
}
|
||||
|
||||
unknownActions := r.ByService("unknown")
|
||||
if len(unknownActions) != 0 {
|
||||
t.Errorf("Expected 0 unknown actions, got %d", len(unknownActions))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
action Action
|
||||
wantSvc string
|
||||
wantRes string
|
||||
wantOp string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
action: "iam:identity:get",
|
||||
wantSvc: "iam",
|
||||
wantRes: "identity",
|
||||
wantOp: "get",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
action: "documents:document:read",
|
||||
wantSvc: "documents",
|
||||
wantRes: "document",
|
||||
wantOp: "read",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
action: "invalid",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
action: "invalid:action",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.action), func(t *testing.T) {
|
||||
svc, res, op, err := ParseAction(tt.action)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ParseAction() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !tt.wantErr {
|
||||
if svc != tt.wantSvc {
|
||||
t.Errorf("service = %v, want %v", svc, tt.wantSvc)
|
||||
}
|
||||
if res != tt.wantRes {
|
||||
t.Errorf("resource = %v, want %v", res, tt.wantRes)
|
||||
}
|
||||
if op != tt.wantOp {
|
||||
t.Errorf("operation = %v, want %v", op, tt.wantOp)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,159 +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.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAccessDenied is returned when access is explicitly denied.
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
|
||||
// ErrNoMatchingPolicy is returned when no policy grants access (implicit deny).
|
||||
ErrNoMatchingPolicy = errors.New("no matching policy")
|
||||
)
|
||||
|
||||
// AccessDeniedError provides detailed information about why access was denied.
|
||||
type AccessDeniedError struct {
|
||||
Principal gid.GID
|
||||
Resource gid.GID
|
||||
Action string
|
||||
Reason string
|
||||
Statement *Statement // The statement that denied access (if explicit deny)
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Error() string {
|
||||
if e.Statement != nil && e.Statement.SID != "" {
|
||||
return fmt.Sprintf("access denied: principal %s cannot perform %s on %s (denied by %s)",
|
||||
e.Principal, e.Action, e.Resource, e.Statement.SID)
|
||||
}
|
||||
return fmt.Sprintf("access denied: principal %s cannot perform %s on %s: %s",
|
||||
e.Principal, e.Action, e.Resource, e.Reason)
|
||||
}
|
||||
|
||||
func (e *AccessDeniedError) Unwrap() error {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Authorizer evaluates policies to authorize actions.
|
||||
type Authorizer struct {
|
||||
evaluator *Evaluator
|
||||
registry *ActionRegistry
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new authorizer with the given action registry.
|
||||
func NewAuthorizer(registry *ActionRegistry) *Authorizer {
|
||||
return &Authorizer{
|
||||
evaluator: NewEvaluator(),
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// AuthorizeParams contains all parameters for an authorization check.
|
||||
type AuthorizeParams struct {
|
||||
// Principal is the actor requesting access.
|
||||
Principal gid.GID
|
||||
|
||||
// Resource is the target resource.
|
||||
Resource gid.GID
|
||||
|
||||
// Action is the operation being performed.
|
||||
Action string
|
||||
|
||||
// Policies are the policies to evaluate (typically role-based + self-manage).
|
||||
Policies []*Policy
|
||||
|
||||
// ResourceAttributes provides additional attributes about the resource
|
||||
// for condition evaluation (e.g., owner_id, tenant_id).
|
||||
ResourceAttributes map[string]string
|
||||
}
|
||||
|
||||
// Authorize checks if the action is allowed based on the provided policies.
|
||||
// Returns nil if allowed, or an error describing why access was denied.
|
||||
func (a *Authorizer) Authorize(params AuthorizeParams) error {
|
||||
// Validate action exists in registry (optional - can be disabled for flexibility)
|
||||
if a.registry != nil && !a.registry.Exists(Action(params.Action)) {
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "unknown action",
|
||||
}
|
||||
}
|
||||
|
||||
// Build condition context
|
||||
conditionCtx := ConditionContext{
|
||||
Principal: map[string]string{
|
||||
"id": params.Principal.String(),
|
||||
},
|
||||
Resource: map[string]string{
|
||||
"id": params.Resource.String(),
|
||||
},
|
||||
}
|
||||
|
||||
// Add resource attributes to context
|
||||
maps.Copy(conditionCtx.Resource, params.ResourceAttributes)
|
||||
|
||||
// Build authorization request
|
||||
req := AuthorizationRequest{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
ConditionContext: conditionCtx,
|
||||
}
|
||||
|
||||
// Evaluate policies
|
||||
result := a.evaluator.Evaluate(req, params.Policies)
|
||||
|
||||
switch result.Decision {
|
||||
case DecisionAllow:
|
||||
return nil
|
||||
|
||||
case DecisionDeny:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "explicitly denied",
|
||||
Statement: result.MatchedStatement,
|
||||
}
|
||||
|
||||
case DecisionNoMatch:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "no policy allows this action",
|
||||
}
|
||||
|
||||
default:
|
||||
return &AccessDeniedError{
|
||||
Principal: params.Principal,
|
||||
Resource: params.Resource,
|
||||
Action: params.Action,
|
||||
Reason: "unexpected evaluation result",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowed is a convenience method that returns true if access is allowed.
|
||||
func (a *Authorizer) IsAllowed(params AuthorizeParams) bool {
|
||||
return a.Authorize(params) == nil
|
||||
}
|
||||
@@ -1,277 +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.
|
||||
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthorizer_Authorize(t *testing.T) {
|
||||
// Create a simple registry for testing
|
||||
registry := NewActionRegistry()
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:get",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "get",
|
||||
Description: "Get resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:update",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "update",
|
||||
Description: "Update resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:resource:delete",
|
||||
Service: "test",
|
||||
Resource: "resource",
|
||||
Operation: "delete",
|
||||
Description: "Delete resource",
|
||||
})
|
||||
registry.MustRegister(ActionDefinition{
|
||||
Action: "test:other:get",
|
||||
Service: "test",
|
||||
Resource: "other",
|
||||
Operation: "get",
|
||||
Description: "Get other",
|
||||
})
|
||||
|
||||
authorizer := NewAuthorizer(registry)
|
||||
|
||||
policies := []*Policy{
|
||||
NewPolicy("test", "Test",
|
||||
Allow("test:resource:get", "test:resource:update"),
|
||||
Deny("test:resource:delete"),
|
||||
),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
wantErr bool
|
||||
errType error
|
||||
}{
|
||||
{
|
||||
name: "allowed action",
|
||||
action: "test:resource:get",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "denied action",
|
||||
action: "test:resource:delete",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "no matching policy",
|
||||
action: "test:other:get",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
{
|
||||
name: "unknown action",
|
||||
action: "unknown:action:here",
|
||||
wantErr: true,
|
||||
errType: ErrAccessDenied,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: policies,
|
||||
ResourceAttributes: map[string]string{
|
||||
"id": "res_123",
|
||||
},
|
||||
})
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr && tt.errType != nil {
|
||||
if !errors.Is(err, tt.errType) {
|
||||
t.Errorf("Authorize() error type = %T, want %T", err, tt.errType)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize_WithConditions(t *testing.T) {
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
// Self-manage policy for testing
|
||||
selfManagePolicy := NewPolicy("self-manage", "Self Manage",
|
||||
Allow("test:identity:get", "test:identity:update").
|
||||
When(Equals("principal.id", "resource.id")),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
resourceAttributes map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "condition not satisfied - GID won't match string",
|
||||
action: "test:identity:get",
|
||||
resourceAttributes: map[string]string{
|
||||
"id": "user_123",
|
||||
},
|
||||
wantErr: true, // Will fail because principal GID won't match string
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: []*Policy{selfManagePolicy},
|
||||
ResourceAttributes: tt.resourceAttributes,
|
||||
})
|
||||
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Authorize() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_Authorize_WithoutRegistry(t *testing.T) {
|
||||
// Authorizer without registry should not validate actions
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
policies := []*Policy{
|
||||
NewPolicy("test", "Test", Allow("custom:action:here")),
|
||||
}
|
||||
|
||||
err := authorizer.Authorize(AuthorizeParams{
|
||||
Action: "custom:action:here",
|
||||
Policies: policies,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for custom action without registry, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_IsAllowed(t *testing.T) {
|
||||
authorizer := NewAuthorizer(nil)
|
||||
|
||||
allowPolicy := NewPolicy("test", "Test", Allow("test:resource:get"))
|
||||
denyPolicy := NewPolicy("test", "Test", Deny("test:resource:delete"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
policies []*Policy
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "allowed",
|
||||
action: "test:resource:get",
|
||||
policies: []*Policy{allowPolicy},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "denied",
|
||||
action: "test:resource:delete",
|
||||
policies: []*Policy{denyPolicy},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no match",
|
||||
action: "test:resource:update",
|
||||
policies: []*Policy{allowPolicy},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := authorizer.IsAllowed(AuthorizeParams{
|
||||
Action: tt.action,
|
||||
Policies: tt.policies,
|
||||
})
|
||||
|
||||
if got != tt.want {
|
||||
t.Errorf("IsAllowed() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessDeniedError(t *testing.T) {
|
||||
t.Run("error message without statement", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "no policy allows this action",
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if msg == "" {
|
||||
t.Error("Expected non-empty error message")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("error message with statement SID", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "explicitly denied",
|
||||
Statement: &Statement{
|
||||
SID: "deny-delete",
|
||||
},
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if msg == "" {
|
||||
t.Error("Expected non-empty error message")
|
||||
}
|
||||
// Should contain the SID
|
||||
if !contains(msg, "deny-delete") {
|
||||
t.Errorf("Expected error message to contain SID, got %q", msg)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unwrap returns ErrAccessDenied", func(t *testing.T) {
|
||||
err := &AccessDeniedError{
|
||||
Action: "test:resource:delete",
|
||||
Reason: "no policy",
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrAccessDenied) {
|
||||
t.Error("Expected error to unwrap to ErrAccessDenied")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string, start int) bool {
|
||||
for i := start; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -20,60 +20,6 @@ import (
|
||||
"go.probo.inc/probo/pkg/iam/policy"
|
||||
)
|
||||
|
||||
func Example_definingActions() {
|
||||
// Create an action registry
|
||||
registry := policy.NewActionRegistry()
|
||||
|
||||
// Register actions for the IAM service
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "iam:identity:get",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "get",
|
||||
Description: "Get identity details",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "iam:identity:update",
|
||||
Service: "iam",
|
||||
Resource: "identity",
|
||||
Operation: "update",
|
||||
Description: "Update identity",
|
||||
})
|
||||
|
||||
// Register actions for the documents service
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:read",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "read",
|
||||
Description: "Read a document",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:write",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "write",
|
||||
Description: "Create or update a document",
|
||||
})
|
||||
|
||||
registry.MustRegister(policy.ActionDefinition{
|
||||
Action: "documents:document:delete",
|
||||
Service: "documents",
|
||||
Resource: "document",
|
||||
Operation: "delete",
|
||||
Description: "Delete a document",
|
||||
})
|
||||
|
||||
// List all actions for a service
|
||||
docActions := registry.ByService("documents")
|
||||
fmt.Printf("Documents service has %d actions\n", len(docActions))
|
||||
|
||||
// Output:
|
||||
// Documents service has 3 actions
|
||||
}
|
||||
|
||||
func Example_definingPolicies() {
|
||||
// Define a viewer policy - can read everything
|
||||
viewerPolicy := policy.NewPolicy("viewer", "Viewer Policy",
|
||||
|
||||
@@ -107,27 +107,3 @@ func NotEquals(key string, values ...string) Condition {
|
||||
}
|
||||
}
|
||||
|
||||
// In creates an In condition.
|
||||
func In(key string, values ...string) Condition {
|
||||
return Condition{
|
||||
Operator: ConditionIn,
|
||||
Key: key,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// NotIn creates a NotIn condition.
|
||||
func NotIn(key string, values ...string) Condition {
|
||||
return Condition{
|
||||
Operator: ConditionNotIn,
|
||||
Key: key,
|
||||
Values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// ForEntityType creates a resource pattern for a specific entity type.
|
||||
func ForEntityType(entityType uint16) ResourcePattern {
|
||||
return ResourcePattern{
|
||||
EntityType: &entityType,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,15 +299,12 @@ func TestConditionHelpers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("In helper", func(t *testing.T) {
|
||||
c := In("principal.role", "admin", "owner")
|
||||
if c.Operator != ConditionIn {
|
||||
t.Errorf("Expected ConditionIn, got %v", c.Operator)
|
||||
t.Run("NotIn condition", func(t *testing.T) {
|
||||
c := Condition{
|
||||
Operator: ConditionNotIn,
|
||||
Key: "principal.role",
|
||||
Values: []string{"guest"},
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NotIn helper", func(t *testing.T) {
|
||||
c := NotIn("principal.role", "guest")
|
||||
if c.Operator != ConditionNotIn {
|
||||
t.Errorf("Expected ConditionNotIn, got %v", c.Operator)
|
||||
}
|
||||
|
||||
@@ -31,25 +31,11 @@ type (
|
||||
provider provider.Provider
|
||||
scimClient *scimclient.Client
|
||||
excludedUserNames []string
|
||||
forceUpdate bool
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
Option func(*Bridge)
|
||||
)
|
||||
|
||||
func WithDryRun(dryRun bool) Option {
|
||||
return func(s *Bridge) {
|
||||
s.dryRun = dryRun
|
||||
}
|
||||
}
|
||||
|
||||
func WithForceUpdate(forceUpdate bool) Option {
|
||||
return func(s *Bridge) {
|
||||
s.forceUpdate = forceUpdate
|
||||
}
|
||||
}
|
||||
|
||||
func WithExcludedUserNames(excludedUserNames []string) Option {
|
||||
return func(s *Bridge) {
|
||||
s.excludedUserNames = excludedUserNames
|
||||
@@ -96,16 +82,13 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
|
||||
existingSCIM, exists := scimUsersByEmail[email]
|
||||
if !exists {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.CreateUser(ctx, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot create user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.CreateUser(ctx, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot create user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
created++
|
||||
} else {
|
||||
needsUpdate := s.forceUpdate ||
|
||||
existingSCIM.Active != pu.Active ||
|
||||
needsUpdate := existingSCIM.Active != pu.Active ||
|
||||
existingSCIM.DisplayName != pu.DisplayName ||
|
||||
existingSCIM.Title != pu.Title ||
|
||||
existingSCIM.GivenName != pu.GivenName ||
|
||||
@@ -120,11 +103,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
existingSCIM.PreferredLanguage != pu.PreferredLanguage
|
||||
|
||||
if needsUpdate {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.UpdateUser(ctx, existingSCIM.ID, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot update user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.UpdateUser(ctx, existingSCIM.ID, &pu); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot update user %q: %w", pu.UserName, err))
|
||||
continue
|
||||
}
|
||||
updated++
|
||||
} else {
|
||||
@@ -139,11 +120,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
}
|
||||
|
||||
if s.isExcluded(email) {
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.DeleteUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot delete user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.DeleteUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot delete user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
continue
|
||||
@@ -153,11 +132,9 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivate
|
||||
continue
|
||||
}
|
||||
|
||||
if !s.dryRun {
|
||||
if err := s.scimClient.DeactivateUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot deactivate user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
if err := s.scimClient.DeactivateUser(ctx, scimUser.ID); err != nil {
|
||||
errs = append(errs, fmt.Errorf("cannot deactivate user %q: %w", email, err))
|
||||
continue
|
||||
}
|
||||
deactivated++
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/pkg/coredata"
|
||||
"go.probo.inc/probo/pkg/gid"
|
||||
"go.probo.inc/probo/pkg/validator"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -36,18 +35,6 @@ func NewSessionService(svc *Service) *SessionService {
|
||||
return &SessionService{Service: svc}
|
||||
}
|
||||
|
||||
type (
|
||||
RevokeAllSessionsRequest struct {
|
||||
CurrentSessionID gid.GID
|
||||
}
|
||||
)
|
||||
|
||||
func (req RevokeAllSessionsRequest) Validate() error {
|
||||
v := validator.New()
|
||||
v.Check(req.CurrentSessionID, "current_session_id", validator.GID(coredata.SessionEntityType))
|
||||
return v.Error()
|
||||
}
|
||||
|
||||
func (s SessionService) GetSession(ctx context.Context, sessionID gid.GID) (*coredata.Session, error) {
|
||||
var (
|
||||
session = &coredata.Session{}
|
||||
|
||||
Reference in New Issue
Block a user