agentlang-ai / agentlang

Generative AI-powered Programming Language
Apache License 2.0
119 stars 4 forks source link

RBAC cascading: permissions for a role should extend to contained instances #506

Closed fractlrao closed 2 years ago

fractlrao commented 2 years ago

fractl now supports a :contains construct (in the context of UI). On the backend, for RBAC, this construct should imply cascading of permissions.

For example, Customer-a contains Account-a1 and Account-a2. Permissions a user/role has on Customer-a (create, read, etc) should cascade down to Account-a1 and Account-a2

vijayfractl commented 2 years ago
(dataflow :Kernel/AppInit
 {:Acme/User
   {:UserName? "admin"} 
  :as :AdminUser}
 ;; ^ admin user may also be created here.

;; Create roles for admin, officer and user.
 {:Kernel/Role 
   {:Name "admin-role" 
    :Assign :AdminUser} 
 :as :AdminRole}
{:Kernel/Role
  {:Name "officer-role"}}
{:Kernel/Role
  {:Name "user-role"}}

 ;; Admin can perform CRUD on all objects in the app model.
;; The `:Delete` action is locked, meaning it can only be overridden by the admin.
 {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :Acme/*
    :Spec {:actions [:Upsert :Lookup :Delete]
           :role :AdminRole
           :lock [:actions :Delete]}}}
 ;; From this point onwards, only admin can create/lookup/delete policies.
  {:Kernel/Policy
    {:Intercept "RBAC"
     :Resource :Kernel/Policy
     :Spec {:actions [:Upsert :Lookup :Delete]
            :role :AdminRole
            :lock :all}}})

A new officer is assigned upsert and lookup permissions on some app entities. An officer also may do policy assignments. The policies on :Acme/Customer will be inherited by :Acme/Account because of the contains relationship.

(dataflow :Acme/CreateOfficer
 {:Acme/User
  {:Username :Acme/CreateOfficer.Username
   ...}
  :as :Officer}

  {:Kernel/Role 
   {:Name "officer-role" 
    :Assign :Officer} 
   :as :OfficerRole}
  {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource [:Acme/User :Acme/Customer]
    :Spec {:actions [:Upsert :Lookup]
           :role :OfficerRole}}}
 ;; an officer can create/lookup policies             
 {:Kernel/Policy
    {:Intercept "RBAC"
     :Resource :Kernel/Policy
     :Spec {:actions [:Upsert :Lookup]
            :role :OfficerRole}})

Every dataflow is executed in the context of a "role" - this has to be automatically inferred by the evaluator by looking at the auth-id in the event-context and loading any attached roles.

The following dataflow tries to assign a user CRUD on :Acme/Customer. The :Delete action for this will succeed only if the current "role" is that of admin. This is because the path :action->:Delete is locked by the admin-role. If UpdatePolicy is executed by an officer, then the policy for :Delete is ignored and will revert to the globally set policy.

(dataflow :Acme/UpdatePolicy
 {:Kernel/Role
  {:Name "user-role"
   :Assign :Acme/UpdatePolicy.User}
 :as :UserRole}
 {:Kernel/Policy
  {:Intercept "RBAC"
   :Resource [:Acme/Customer]
   :Spec {:actions [:Upsert :Lookup :Delete]
          :role :UserRole}}})

I think this approach for controlling parts of a spec from being overridden at lower-levels should work for other use cases as well - like for controlling the view-config of entities.

fractlrao commented 2 years ago

The above approach looks good for the RBAC use case. Some minor comments:

  1. :role should be a vector and can be either a role or a user. e.g., a :Bid created by a particular officer should be editable only by that officer and not by other officers with the same role
  2. It would help to assign roles to users, instead of assigning users to role. This can be done via :UserRole (:Role :contains :UserRole)

    (dataflow :Acme/CreateOfficer
    
          ;; Get role
          {:Kernel/Role
           {:Name? "officer-role"}
           :as :OfficerRole}
    
          ;; Create user
          {:Acme/User
           {:Username :Acme/CreateOfficer.Username
            …
            :Role :OfficerRole}
           :as :Officer}
    
          ;; Assign role to officer
          {:Acme/UserRole
           {:User :Officer
            …
            :Role :OfficerRole}}
    
          {:Kernel/Policy
           {:Intercept "RBAC"
            :Resource [:Acme/User :Acme/Customer]
            :Spec {:actions [:Upsert :Lookup]
                   :role :OfficerRole}}}
    
          ;; an officer can create/lookup policies
          {:Kernel/Policy
           {:Intercept "RBAC"
            :Resource :Kernel/Policy
            :Spec {:actions [:Upsert :Lookup]
                   :role :OfficerRole}}})
  3. It would also help to have a construct that specifies that :Supervisor for a given department is allowed to do anything that an :Officer is allowed to do.
vijayfractl commented 2 years ago

@fractlrao

  1. It is possible to assign RBAC to individual instances, as shown below:
(dataflow :Acme/CreateBid
 ;; Creating a new bid will fail if the user does not have officer-role,
;; this is globally-defined policy as described earler.
 {:Acme/Bid
   {:BidBy :Acme/CreateBid.UserId
    ...} :as :NewBid}

 {:Acme/User {:Id? :Acme/CreateBid.UserId} :as :User}

;; Only the user who created the bid can update and query it.
  {:Kernel/Policy
    {:Intercept "RBAC"
     :Resource :NewBid 
     :Spec {:actions [:Upsert :Lookup]
            :role :User}}})
  1. Assigning roles to users will have to assume some convention to be followed in identity related entities - such as having a reserved attribute :Role. I felt it is better to maintain assignment separately from the structure of user-defined entities.
vijayfractl commented 2 years ago

:Kernel/Policy can be used to control UI generation for entities, events etc. An example is shown below:

(entity :Acme/Transaction
 {:Debit {:ref :Acme/Account}
  :Credit {:ref :Acme/Account}
  :Amount :Kernel/Decimal
  :CreatedBy {:ref :Acme/User}
  :Date :Kernel/DateTime})

(dataflow :Kernel/AppInit
;; ...
{:Kernel/Policy
 {:Intercept "views"
  :Resource :Acme/Transaction
  :Spec {:instance :Acme/ViewTransaction
        :styles {:table ...}}}}

The same policy could be defined as part of the entity definition. The compiler will lift the spec into :Kernel/Policy instance, which will be auto-evaluated at app-init time:

(entity :Acme/Transaction
 {:Debit {:ref :Acme/Account}
  ...
  :policies
  {:views {:instance :Acme/ViewTransaction
           :styles {:table ...}}}})
vijayfractl commented 2 years ago

@fractlrao Given the facility to assign roles directly to user ids, we may no longer need the :Kernel/Role entity.

The RBAC spec can support more granularity, as shown below:

{:Kernel/Policy
 {:Intercept "RBAC"
  :Resource :Acme/User
  :Spec {:actions {:Upsert [:FirstName :LastName :Address] 
                   :Lookup [:FirstName :LastName :Address :Email]}
         :assign-to :SomeUser}}}

{:Kernel/Policy
 {:Intercept "RBAC"
  :Resource :Acme/Department
  :Spec {:actions {:Upsert [[:Title :when [:= DeptNo 101]]]}
         :assign-to :SomeUser}}}

We already have a rules compiler for RBAC, which can be reused for the second scenario.

A "Kernel/Rule entity may be used to define reusable rules as part of the workflow. This will also lead to persisted policies taking less space on disk.

(dataflow :Acme/DefineSupervisorRole
 {:Kernel/Rule
  {:Name "dept-supervisor"
   :Rule :Acme/DefineSupervisorRole.Rule ;; {:actions {:Upsert [[:Title :when [:= DeptNo 101]]]}}
  }})

(dataflow :Acme/AssignSupervisor
  {:Kernel/Rule {:Name? "dept-supervisor"} :as :Rule}
  {:Acme/User {:Id? :Acme/AssignSupervisor.UserId} :as :User}
  {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :Acme/Department
    :Spec {:rule :Rule :assign-to :User}}})

We can still retain the ability to define rules directly in the policy instance.

fractlrao commented 2 years ago

@vmatv8, thank you - being able to support more granular policies and reusable rules is helpful.

We will still need the :Kernel/Role (or equivalent) capability to be able to manage permissions for a group of users. There are 2 use cases:

  1. Permissions for a group of users: For example, we might have supervisors (entity :Employee with :Type set to supervisor) and officers (entity :Employee with :Type set to officer). All supervisors might be allowed to perform some admin actions, where as officers aren’t. It would help to have a single :Kernel/Policy entry for the supervisor role, instead of one entry per role.
  2. Permissions for a given user: A logged in user of type :Officer is allowed to modify only their profile/bids/…

Would it be possible to enforce policy based on :Employee type being supervisor vs officer for (1)?

vijayfractl commented 2 years ago

@fractlrao I think we should model RBAC using the following constructs:

(entity :Kernel/Role
 {:Name {:type :Kernel/String :unique true}})

  (entity
   :Kernel/RoleAssignment
   {:Role {:ref :Kernel/Role.Name}
    :Assignee :Kernel/Entity})

(entity :Kernel/Policy
 {:Intercept :Kernel/Keyword
  :Resource :Kernel/Path
  :Spec :Kernel/Edn})

Application-level RBAC management can be done as,

(entity :Acme/User
 {:Username :Kernel/String
  :Password :Kernel/Password})

(dataflow :Kernel/AppInit
 {:Kernel/Role
  {:Name "supervisor"} :as :Supervisor})

  {:Kernel/Role
   {:Name "officer"} :as :Officer}

  {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :* ;; all resources
    :Spec {:actions [:Upsert :Delete :Lookup]
           :role :Supervisor}}}

  {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :Acme/Department
    :Spec {:actions [:Lookup]
           :role :Officer}}}

  {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :Acme/Product
    :Spec {:actions [:Lookup :Upsert]
           :role :Officer}}}

(dataflow :Acme/CreateSupervisor
  {:Acme/User
    {:Username :Acme/CreateSupervisor.Username
    ...} :as :U}
  {:Kernel/RoleAssignment
   {:Role "supervisor" 
    :Assignee :U}})

(dataflow :Acme/CreateOfficer
 {:Acme/User
   {:Username :Acme/CreateOfficer.Username
    ...} :as :U}
 {:Kernel/RoleAssignment
   {:Role "officer" 
    :Assignee :U}})

The implementation will require a per-thread execution context which stores a reference to the user that initiated the request (obtained from the auth-info of the API request). Each CRUD operation will check the roles assigned to this user, merge any granular policies defined at different levels (contains hierarchy) and apply the most restrictive rule to the operation. An example is shown below:

(entity :E1 { ... })
(entity :E2 { ... {:meta {:contains [:E1]}}})

(dataflow :Kernel/AppInit
 {:Kernel/Role
  {:Name "role-1"} :as :R1}
 {:Kernel/Role
  {:Name "role-2"} :as :R2}

;; Anyone with role :R1 has full CRUD on :E2 and :E1 (by way of contains). 
 {:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :E2
    :Spec {:actions [:Upsert :Delete :Lookup]
           :role :R1}}}

;; Role :R2 will remove the :Delete permission for `:E1`
{:Kernel/Policy
   {:Intercept "RBAC"
    :Resource :E1
    :Spec {:actions [:Upsert :Lookup]
           :role :R2}}})

(dataflow :CreateUser
 {:User {:Username :CreateUser,Name ...} :as :U}
 {:Kernel/RoleAssignment
  {:Role "role-1" :Assignee :U}}
 [:match :User.DeptNo
  101 {:Kernel/RoleAssignment {:Role "role-2" :Assignee :U}}])

Users belonging to department 101 has limited permissions on :E1. The RBAC mechanism will find this minimal allowable permission by merging the two roles assigned to the user.