OpenACS 4.x Permissions Tediously Explained
by Vadim Nasardinov. Modified and converted to Docbook XML by Roberto Mello
Overview
The general permissions system has a relatively complex data model in OpenACS 4.x.
Developers who haven't had the time to learn the internals of the data model
may end up writing seemingly correct code that crashes their system in
weird ways. This writeup is the result of my running into such a piece
of code and trying to understand exactly what went wrong.
It is geared towards developers who understand the general permissions
system to the extent that is described in the
OpenACS 4.x Permisisons documentation,
but who haven't had the opportunity to take a long, careful look at the
system internals.
In OpenACS 4.x, most of the interesting tables are expected to extend (subtype)
the acs_objects table, i.e. they are expected to have an integer
primary key column that references the object_id column of
acs_objects.
create table acs_objects (
object_id integer
not null
constraint acs_objects_pk primary key,
object_type
not null
constraint acs_objects_object_type_fk references acs_object_types (object_type),
context_id
constraint acs_objects_context_id_fk references acs_objects(object_id),
security_inherit_p char(1) default 't'
not null,
constraint acs_objects_sec_inherit_p_ck
check (security_inherit_p in ('t', 'f')),
creation_user integer,
creation_date date default sysdate not null,
creation_ip varchar2(50),
last_modified date default sysdate not null,
modifying_user integer,
modifying_ip varchar2(50),
constraint acs_objects_context_object_un
unique (context_id, object_id) disable
);
This means that any interesting entity (object)
in the system has an entry in the acs_objects. This
allows developers to define relationships between any two entities A
and B by defining a relationship between their corresponding entries
in the acs_objects table. One of the applications of this
powerful capability is the general permissions system.
At the heart of the permission system are two tables: acs_privileges
and acs_permissions.
create table acs_privileges (
privilege varchar2(100) not null
constraint acs_privileges_pk primary key,
pretty_name varchar2(100),
pretty_plural varchar2(100)
);
create table acs_permissions (
object_id
not null
constraint acs_permissions_on_what_id_fk references acs_objects (object_id),
grantee_id
not null
constraint acs_permissions_grantee_id_fk references parties (party_id),
privilege
not null
constraint acs_permissions_priv_fk references acs_privileges (privilege),
constraint acs_permissions_pk
primary key (object_id, grantee_id, privilege)
);
The acs_privileges table stores
named privileges like read,
write, delete, create, and
admin. The acs_permissions
table stores assertions of the form:
Who (grantee_id) can do what (privilege)
on which object (object_id).
The naive approach to managing system security would be to require application developers
to store permission information explicitly about every object, i.e. if the system has 100,000 and 1,000 users
who have the read privilege on all objects, then we would need to store 100,000,000
entries of the form:
object_id
grantee_id
privilege
object_id_1
user_id_1
'read'
object_id_1
user_id_2
'read'
...
object_id_1
user_id_n
'read'
object_id_2
user_id_1
'read'
object_id_2
user_id_2
'read'
...
object_id_2
user_id_n
'read'
...
...
object_id_m
user_id_1
'read'
object_id_m
user_id_2
'read'
...
object_id_m
user_id_n
'read'
Although quite feasible, this approach fails to take advantage of the fact
that objects in the system are commonly organized hierarchally,
and permissions usually follow the hierarchical structure, so that if user
X has the read privilege on object A, she typically
also has the read privilege on all objects attached under A.
The general permission system, as implemented in OpenACS 4.x, takes advantage
of the hierarchical organization of objects to unburden developers of the
necessity to explicitly maintain security information for every single
object. There are three kinds of hierarchies involved.
These are discussed in the following sections.
Context Hierarchy
Suppose objects A, B, ...,
and F form the following hierarchy.
A
object_id=10
B
object_id=20
C
object_id=30
D
object_id=40
E
object_id=50
F
object_id=60
This can be represented in the
acs_objects table
by the following entries:
object_id
context_id
20
10
30
10
40
20
50
20
60
30
The first entry tells us that object 20 is the descendant of object 10, and
the third entry shows that object 40 is the descendant of object 20. By
running a CONNECT BY query,
we can compute that object 40 is the second-generation descendant of object 10.
With this in mind, if we want to record the fact that user Joe has the read privilege on objects
A, ..., F, we only need to record one entry in the
acs_permissions table.
object
grantee
privilege
A
Joe
read
The fact that Joe can also read B, C,
..., and F can be derived by ascertaining that these objects
are children of A by traversing the context hierarchy.
As it turns out, hierarchical queries are expensive. As
Rafael Schloming put it so aptly, Oracle can't deal with hierarchies for shit.
One way to solve this problem is to cache a flattened view of the context tree like so:
object
ancestor
n_generations
A
A
0
B
B
0
B
A
1
C
C
0
C
A
1
D
D
0
D
B
1
D
A
2
E
E
0
E
B
1
E
A
2
F
F
0
F
C
1
F
A
2
Note that the number of entries in the flattened view grows exponentially with
respect to the depth of the context tree. For instance, if you have a fully
populated binary tree with a depth of n, then the number of entries
in its flattened view is
1 + 2*2 + 3*4 + 4*8 + 5*16 + ... + (n+1)*2n = n*2n+1 + 1
Despite its potentially great storage costs, maintaining a
flattened representation of the context tree is exactly what OpenACS 4.x
does. The flattened context tree is stored in the
acs_object_context_index table.
create table acs_object_context_index (
object_id
not null
constraint acs_obj_context_idx_obj_id_fk references acs_objects(object_id),
ancestor_id
not null
constraint acs_obj_context_idx_anc_id_fk references acs_objects(object_id),
n_generations integer
not null
constraint acs_obj_context_idx_n_gen_ck check (n_generations >= 0),
constraint acs_object_context_index_pk
primary key (object_id, ancestor_id)
) organization index;
A few things to note about this table are these. Number one, it is
an index-organized
table, which means it is substantially optimized for access by primary key.
Number two, as the above computations suggest, the size of the table
grows polynomially
with respect to the average number of descendants that an object
has, and exponentially
with respect to the depth of the context tree.
The acs_object_context_index is kept in sync with the
acs_objects
table by triggers like this:
create or replace trigger acs_objects_context_id_in_tr
after insert on acs_objects
for each row
begin
insert into acs_object_context_index
(object_id, ancestor_id, n_generations)
values
(:new.object_id, :new.object_id, 0);
if :new.context_id is not null and :new.security_inherit_p = 't' then
insert into acs_object_context_index
(object_id, ancestor_id,
n_generations)
select
:new.object_id as object_id, ancestor_id,
n_generations + 1 as n_generations
from acs_object_context_index
where object_id = :new.context_id;
elsif :new.object_id != 0 then
-- 0 is the id of the security context root object
insert into acs_object_context_index
(object_id, ancestor_id, n_generations)
values
(:new.object_id, 0, 1);
end if;
end;
One final note about
acs_objects. By setting
an object's security_inherit_p column to 'f', you can stop permissions
from cascading down the context tree. In the following example, Joe does not have
the read permissions on C and F.
A
object_id=10
readable by Joe
B
object_id=20
readable by Joe
C
object_id=30
security_inherit_p = 'f'
not readable by Joe
D
object_id=40
E
object_id=50
F
object_id=60
security_inherit_p = 'f'
not readable by Joe
Privilege Hierarchy
Privileges are also organized hierarchically. In addition to the five main system privileges
defined in the ACS Kernel data model, application developers may define their own. For instance,
the Bboard package defines the following privileges:
privilege
create_category
create_forum
create_message
delete_category
delete_forum
delete_message
moderate_forum
read_category
read_forum
read_message
write_category
write_forum
write_message
By defining parent-child relationship between privileges, the OpenACS data model
makes it easier for developers to manage permissions. Instead of granting
a user explicit read, write, delete,
and create
privileges on an object, it is sufficient to grant the user the admin
privilege to which the first four privileges are tied. To give
a more detailed example, the Bboard privileges are structured
as follows.
admin
create
delete
read
write
moderate forum
create category
create forum
create message
delete category
delete forum
delete message
read category
read forum
read message
write category
write forum
write message
The parent-child relationship between privileges is represented in
the acs_privilege_hierarchy table:
create table acs_privilege_hierarchy (
privilege
not null
constraint acs_priv_hier_priv_fk references acs_privileges (privilege),
child_privilege
not null
constraint acs_priv_hier_child_priv_fk references acs_privileges (privilege),
constraint acs_privilege_hierarchy_pk
primary key (privilege, child_privilege)
);
As in the case of the context hierarchy, it is convenient to have a flattened representation
of this hierarchal structure. This is accomplished by defining the following view.
create or replace view acs_privilege_descendant_map
as
select
p1.privilege,
p2.privilege as descendant
from
acs_privileges p1,
acs_privileges p2
where
p2.privilege in
(select
child_privilege
from
acs_privilege_hierarchy
start with
privilege = p1.privilege
connect by
prior child_privilege = privilege
)
or p2.privilege = p1.privilege;
As the number of different privileges in the system is expected to be
reasonably small, there is no pressing need to cache the flattened ansector-descendant
view of the privilege hierarchy in a specially maintained table like
it is done in the case of the context hierarchy.
Party Hierarchy
Now for the third hierarchy playing a promiment role in the permission system. The party
data model is set up as follows.
parties
persons
groups
users
create table parties (
party_id
not null
constraint parties_party_id_fk references acs_objects (object_id)
constraint parties_pk primary key,
email varchar2(100)
constraint parties_email_un unique,
url varchar2(200)
);
create table persons (
person_id
not null
constraint persons_person_id_fk references parties (party_id)
constraint persons_pk primary key,
first_names varchar2(100)
not null,
last_name varchar2(100)
not null
);
create table users (
user_id
not null
constraint users_user_id_fk references persons (person_id)
constraint users_pk primary key,
password char(40),
-- other attributes
);
create table groups (
group_id
not null
constraint groups_group_id_fk references parties (party_id)
constraint groups_pk primary key,
group_name varchar2(100) not null
);
Recall that the grantee_id column of the
acs_permissions table references
parties.party_id.
This means that you can grant a privilege on an object to a party, person, user, or group.
Groups represent aggregations of parties. The most common scenario that you are likely
to encounter is a group that is a collection of users, although you could also
have collections of persons, groups, parties, or any mix thereof.
Given that the most common use of groups is to partition users, how do you
build groups? One way is to grant membership explicitly. If you have
a group named Pranksters, you can assign membership to Pete,
Poly, and Penelope. The fact that these users are members of the
Pranksters group will be recorded in the
membership_rels and acs_rels tables:
create table acs_rels (
rel_id
not null
constraint acs_rels_rel_id_fk references acs_objects (object_id)
constraint acs_rels_pk primary key,
rel_type
not null
constraint acs_rels_rel_type_fk references acs_rel_types (rel_type),
object_id_one
not null
constraint acs_object_rels_one_fk references acs_objects (object_id),
object_id_two
not null
constraint acs_object_rels_two_fk references acs_objects (object_id),
constraint acs_object_rels_un
unique (rel_type, object_id_one, object_id_two)
);
create table membership_rels (
rel_id
constraint membership_rel_rel_id_fk references acs_rels (rel_id)
constraint membership_rel_rel_id_pk primary key,
-- null means waiting for admin approval
member_state varchar2(20)
constraint membership_rel_mem_ck
check (member_state in ('approved', 'banned', 'rejected', 'deleted'))
);
The acs_rels
table entries would look like so:
rel_type
object_one
object_two
membership_rel
Pranksters
Pete
membership_rel
Pranksters
Poly
membership_rel
Pranksters
Penelope
Another way of building up groups is by adding subgroups. Suppose
we define Merry Pranksters and Sad Pranksters as subgroups
of Pranksters. We say that the Pranksters group
is composed of
groups Merry Pranksters and Sad Pranksters. This
information is stored in the acs_rels
and composition_rels tables.
create table composition_rels (
rel_id
constraint composition_rel_rel_id_fk references acs_rels (rel_id)
constraint composition_rel_rel_id_pk primary key
);
The relevant entries in the
acs_rels look like so.
rel_type
object_one
object_two
composition_rel
Pranksters
Merry Pranksters
composition_rel
Pranksters
Sad Pranksters
The composition relationship means that if I add Matt, Mel, and Mary to the
Merry Pranksters,
they should also automatically become members of the Pranksters group.
The situation we are
facing in trying to determine whether or not a user is member of a group is similar to the one
discussed above in the case of the context hierarchy. Groups can form hierarchies with
respect to the composition relationship. The compositon relationship is transitive. If
G1 is a subgroup of G2, and G2 is a subgroup of G3, then
G1 is a subgroup of G3; that is, any member of G1 is also a member
of G3.
Traversing the group composition hierarchy requires running
hierarchical queries,
which are expensive in Oracle. As we saw in the Context Hierarchy section, one way of
reducing the performance hit incurred by hierarchical queries is to cache query results in
a table maintained by triggers. The OpenACS 4.x data model defines two such tables:
create table group_component_index (
group_id not null
constraint group_comp_index_group_id_fk
references groups (group_id),
component_id not null
constraint group_comp_index_comp_id_fk
references groups (group_id),
rel_id not null
constraint group_comp_index_rel_id_fk
references composition_rels (rel_id),
container_id not null
constraint group_comp_index_cont_id_ck
references groups (group_id),
constraint group_component_index_ck
check (group_id != component_id),
constraint group_component_index_pk
primary key (group_id, component_id, rel_id)
) organization index;
create table group_member_index (
group_id
not null
constraint group_member_index_grp_id_fk references groups (group_id),
member_id
not null
constraint group_member_index_mem_id_fk references parties (party_id),
rel_id
not null
constraint group_member_index_rel_id_fk references membership_rels (rel_id),
container_id
not null
constraint group_member_index_cont_id_fk references groups (group_id),
constraint group_member_index_pk
primary key (member_id, group_id, rel_id)
) organization index;
The group_component_index table stores a flattened representation of the
group composition hierarchy that is maintained in sync with the acs_rels
and composition_rels tables through triggers.
As far as the group_member_index table goes, I am not sure I understand its
purpose. It maintains group-member relationships that are resolved with respect
to group composition. Note that information stored in
group_member_index can be trivially derived by joining
membership_rels,
acs_rels,
and group_component_index. Here
is a view that does it. (This view is not part of the OpenACS Kernel data model.)
create or replace view group_member_view
as
select
gci.group_id, r.object_id_two as member_id
from
(
select
group_id, group_id as component_id
from
groups
union
select
group_id, component_id
from
group_component_index
) gci,
membership_rels mr,
acs_rels r
where
mr.rel_id = r.rel_id
and r.object_id_one = gci.component_id;
A heuristic way to verify that group_member_view is essentially identical
to group_member_index is to compute the
symmetric difference between the two:
select
group_id, member_id
from
(
select group_id, member_id from group_member_view
minus
select group_id, member_id from group_member_index
)
union
select
group_id, member_id
from
(
select group_id, member_id from group_member_index
minus
select group_id, member_id from group_member_view
)
The query returns no rows. The important point is, if we
have a flattened view of the composition hierarchy -- like one provided
by the group_component_index table --
membership relationship resolution can be computed trivially with no hierarchical
queries involved. There is no need to keep the view in a denormalized
table, unless doing so results in substantial performance gains.
Putting It All Together
Security information is queried by calling the acs_permission.permission_p
function in OpenACS 4.x.
create or replace package body acs_permission
as
-- some stuff removed for the sake of brevity
function permission_p (
object_id acs_objects.object_id%TYPE,
party_id parties.party_id%TYPE,
privilege acs_privileges.privilege%TYPE
) return char
as
exists_p char(1);
begin
-- XXX This must be fixed: -1 shouldn't be hardcoded (it is the public)
select decode(count(*),0,'f','t') into exists_p
from acs_object_party_privilege_map
where object_id = permission_p.object_id
and party_id in (permission_p.party_id, -1)
and privilege = permission_p.privilege;
return exists_p;
end;
end acs_permission;
The function simply queries
acs_object_party_privilege_map,
which is a humongous view that joins three flattened hierarchies:
the context tree, the privilege hierarchy,
the party composition (and membership) hierarchy. As such,
it contains an extremely large number of rows. About
the only kind of query you can run against it is the one
performed by the acs_permission.permission_p
function. Anything other than that would take forever to
finish or would ultimately result in an Oracle error.
For example, do not try to do things like
select count(*)
from acs_object_party_privilege_map;
To give another example of things to avoid, I have seen code like this:
declare
cursor cur is
select
object_id, party_id
from
acs_object_party_privilege_map
where
privilege = 'foo_create';
begin
-- revoke all 'foo_create' permissions
for rec in cur
loop
acs_permission.revoke_permission (
object_id => rec.object_id,
grantee_id => rec.party_id,
privilege => 'foo_create'
);
end loop;
acs_privilege.remove_child('admin','foo_create');
acs_privilege.drop_privilege('foo');
end;
/
The acs_permission.revoke_permission function merely runs a
delete statement like so:
delete from
acs_permissions
where
object_id = revoke_permission.object_id
and grantee_id = revoke_permission.grantee_id
and privilege = revoke_permission.privilege;
Note that in the above example, acs_permissions had only
one entry that needed to be deleted:
object_id
grantee_id
privilege
default_context
registered_users
foo_create
The above script would never get around to deleting this entry because it had
to loop through a gazillion rows in the humongous
acs_object_party_privilege_map view.
Appendix: Various View Definitions
create or replace view acs_object_party_privilege_map
as
select
ogpm.object_id,
gmm.member_id as party_id,
ogpm.privilege
from
acs_object_grantee_priv_map ogpm,
group_member_map gmm
where
ogpm.grantee_id = gmm.group_id
union
select
object_id,
grantee_id as party_id,
privilege
from
acs_object_grantee_priv_map;
create or replace view acs_object_grantee_priv_map
as
select
a.object_id,
a.grantee_id,
m.descendant as privilege
from
acs_permissions_all a,
acs_privilege_descendant_map m
where
a.privilege = m.privilege;
create or replace view acs_permissions_all
as
select
op.object_id,
p.grantee_id,
p.privilege
from
acs_object_paths op,
acs_permissions p
where
op.ancestor_id = p.object_id;
create or replace view acs_object_paths
as
select
object_id,
ancestor_id,
n_generations
from
acs_object_context_index;
create or replace view group_member_map
as
select
group_id,
member_id,
rel_id,
container_id
from
group_member_index;