Reputation: 106549
Alright, I've got an access control list system which mimics NT DACLs. Basically, I've got users, groups, a membership mapping between them, and ACLs with any number of ACEs referencing users or groups.
(For example, this lets the "Marketing Department" group access to something, but Joe who works in marketing is a problem child, so you could deny him access to that something, and he would be denied, but everyone else in the group would be allowed)
I need to enumerate a list of objects which are controlled by a given ACL -- in this case, the "controlled objects" are the user objects themselves. For example, let's say user Bob (with uid=1
) wants to delete another user from the system, and I want a list of users to show Bob on which he may perform that action. If a user (denoted here by the WHERE usr.id = 1
(the "1" would be cached by the PHP app this is being embedded into)) has access to the given object, I want to show it, and if (s)he does not, then it shouldn't exist in the result set.
Here's the best I've come up with so far:
SELECT `acelist`.id, `acelist`.first_name, `acelist`.last_name, `acelist`.acl
FROM
(
(
SELECT `usResult`.id, `usResult`.first_name, `usResult`.last_name, `usResult`.acl, `ace`.`allowed`
FROM `user` usResult
INNER JOIN access_control_list acl ON usResult.acl = acl.id
INNER JOIN group_access_control_entry ace ON acl.id = ace.acl
INNER JOIN `group` gp ON ace.gid = gp.id
INNER JOIN group_membership ON gp.id = group_membership.gid
INNER JOIN `user` usr ON group_membership.uid = usr.id
WHERE usr.id = 1
)
UNION ALL
(
SELECT `usResult`.id, `usResult`.first_name, `usResult`.last_name, `usResult`.acl, `ace`.`allowed`
FROM `user` usResult
INNER JOIN access_control_list acl ON usResult.acl = acl.id
INNER JOIN user_access_control_entry ace ON acl.id = ace.acl
INNER JOIN `user` usr ON ace.uid = usr.id
WHERE usr.id = 1
)
) AS acelist
GROUP BY `acelist`.id
HAVING COUNT(acelist.allowed) = SUM(acelist.allowed)
And here's the schema I'm working with:
# Generated by Propel ORM
# This is a fix for InnoDB in MySQL >= 4.1.x
# It "suspends judgement" for fkey relationships until are tables are set.
SET FOREIGN_KEY_CHECKS = 0;
-- ---------------------------------------------------------------------
-- user
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`
(
`id` INTEGER NOT NULL,
`first_name` VARCHAR(255) NOT NULL,
`last_name` VARCHAR(255) NOT NULL,
`direct_login` INTEGER,
`acl` INTEGER NOT NULL,
PRIMARY KEY (`id`),
INDEX `user_FI_1` (`acl`),
CONSTRAINT `user_FK_1`
FOREIGN KEY (`acl`)
REFERENCES `access_control_list` (`id`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- case_id_user
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `case_id_user`;
CREATE TABLE `case_id_user`
(
`uid` INTEGER NOT NULL,
`case_id` VARCHAR(8),
PRIMARY KEY (`uid`),
UNIQUE INDEX `case_id_user_U_1` (`case_id`),
CONSTRAINT `case_id_user_FK_1`
FOREIGN KEY (`uid`)
REFERENCES `user` (`id`)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- direct_login_user
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `direct_login_user`;
CREATE TABLE `direct_login_user`
(
`uid` INTEGER NOT NULL,
`passhash` CHAR(60) NOT NULL,
`email` VARCHAR(255) NOT NULL,
`user_name` VARCHAR(45) NOT NULL,
PRIMARY KEY (`uid`),
UNIQUE INDEX `direct_login_user_U_1` (`user_name`),
CONSTRAINT `direct_login_user_FK_1`
FOREIGN KEY (`uid`)
REFERENCES `user` (`id`)
ON UPDATE CASCADE
ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- group
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `group`;
CREATE TABLE `group`
(
`id` INTEGER NOT NULL,
`name` VARCHAR(45) NOT NULL,
`description` TEXT NOT NULL,
`acl` INTEGER NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `group_U_1` (`name`),
INDEX `group_FI_1` (`acl`),
CONSTRAINT `group_FK_1`
FOREIGN KEY (`acl`)
REFERENCES `access_control_list` (`id`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- privilege
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `privilege`;
CREATE TABLE `privilege`
(
`id` INTEGER NOT NULL AUTO_INCREMENT,
`name` VARCHAR(45) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `privilege_U_1` (`name`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- access_control_list
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `access_control_list`;
CREATE TABLE `access_control_list`
(
`id` INTEGER NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- user_access_control_entry
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `user_access_control_entry`;
CREATE TABLE `user_access_control_entry`
(
`acl` INTEGER NOT NULL,
`uid` INTEGER NOT NULL,
`privilege_id` INTEGER NOT NULL,
`allowed` TINYINT NOT NULL,
PRIMARY KEY (`acl`,`uid`,`privilege_id`,`allowed`),
INDEX `user_access_control_entry_FI_1` (`privilege_id`),
INDEX `user_access_control_entry_FI_2` (`uid`),
CONSTRAINT `user_access_control_entry_FK_1`
FOREIGN KEY (`privilege_id`)
REFERENCES `privilege` (`id`)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT `user_access_control_entry_FK_2`
FOREIGN KEY (`uid`)
REFERENCES `user` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE,
CONSTRAINT `user_access_control_entry_FK_3`
FOREIGN KEY (`acl`)
REFERENCES `access_control_list` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- group_access_control_entry
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `group_access_control_entry`;
CREATE TABLE `group_access_control_entry`
(
`acl` INTEGER NOT NULL,
`gid` INTEGER NOT NULL,
`privilege_id` INTEGER NOT NULL,
`allowed` TINYINT NOT NULL,
PRIMARY KEY (`acl`,`gid`,`privilege_id`,`allowed`),
INDEX `group_access_control_entry_FI_1` (`privilege_id`),
INDEX `group_access_control_entry_FI_2` (`gid`),
CONSTRAINT `group_access_control_entry_FK_1`
FOREIGN KEY (`privilege_id`)
REFERENCES `privilege` (`id`)
ON UPDATE CASCADE
ON DELETE CASCADE,
CONSTRAINT `group_access_control_entry_FK_2`
FOREIGN KEY (`gid`)
REFERENCES `group` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE,
CONSTRAINT `group_access_control_entry_FK_3`
FOREIGN KEY (`acl`)
REFERENCES `access_control_list` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE
) ENGINE=InnoDB;
-- ---------------------------------------------------------------------
-- group_membership
-- ---------------------------------------------------------------------
DROP TABLE IF EXISTS `group_membership`;
CREATE TABLE `group_membership`
(
`uid` INTEGER NOT NULL,
`gid` INTEGER NOT NULL,
PRIMARY KEY (`uid`,`gid`),
INDEX `group_membership_FI_2` (`gid`),
CONSTRAINT `group_membership_FK_1`
FOREIGN KEY (`uid`)
REFERENCES `user` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE,
CONSTRAINT `group_membership_FK_2`
FOREIGN KEY (`gid`)
REFERENCES `group` (`id`)
ON UPDATE RESTRICT
ON DELETE CASCADE
) ENGINE=InnoDB;
# This restores the fkey checks, after having unset them earlier
SET FOREIGN_KEY_CHECKS = 1;
Do any of the SQL gurus here see anything I might do to either simplify the query, or to make it execute more quickly?
EDIT: Ideally, I'd somehow be able to make the complicated bits of this work for any ACL'd object so that I could avoid having to write a query like this for every ACL'd object in the database....
Upvotes: 1
Views: 243
Reputation: 12704
Depending on the number of your users and the frequency of authorised actions you might want to consider caching the whole permission subtree for a specific user.
You can insist on ACID cache by keeping track of last time when the permissions tables had been updated and this gives you a quick query that can detect if the slower one needs to run.
Otherwise you queries look ok; I don't like the COUNT(allowed) = SUM(allowed) to establish that there are no zeros. The NOT EXIST variant should be able to optimize compared to this approach which must calculate COUNT() and SUM() over all records. However assuming sane data distribution I would expect much larger win factor for caching the permissions.
Upvotes: 0
Reputation: 58441
Certainly not prettier but it might be faster because there's less data to juggle with.
SELECT `acelist`.id, `acelist`.first_name, `acelist`.last_name, `acelist`.acl
FROM `usResult` acl
INNER JOIN (
SELECT `usResult`.id
FROM (
SELECT `usResult`.id
, SUM(`ace`.`allowed`) AS SumAllowed
, COUNT(`ace`.`allowed`) AS CountAllowed
FROM `user` usResult
INNER JOIN access_control_list acl ON usResult.acl = acl.id
INNER JOIN group_access_control_entry ace ON acl.id = ace.acl
INNER JOIN `group` gp ON ace.gid = gp.id
INNER JOIN group_membership ON gp.id = group_membership.gid
INNER JOIN `user` usr ON group_membership.uid = usr.id
WHERE usr.id = 1
GROUP BY
`usResult`.id
UNION ALL
SELECT `usResult`.id
, SUM(`ace`.`allowed`) AS SumAllowed
, COUNT(`ace`.`allowed`) AS CountAllowed
FROM `user` usResult
INNER JOIN access_control_list acl ON usResult.acl = acl.id
INNER JOIN user_access_control_entry ace ON acl.id = ace.acl
INNER JOIN `user` usr ON ace.uid = usr.id
WHERE usr.id = 1
GROUP BY
`usResult`.id
) results
GROUP BY
results.id
HAVING SUM(results.SumAllowed) = SUM(results.CountAllowed)
) r ON r.id = acl.id
Upvotes: 1