/******************************************************************************* * Copyright (c) 2013, 2014 Lectorius, Inc. * Authors: * Vijay Pandurangan (vijayp@mitro.co) * Evan Jones (ej@mitro.co) * Adam Hilss (ahilss@mitro.co) * * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. * * You can contact the authors at inbound@mitro.co. *******************************************************************************/ package co.mitro.core.accesscontrol; import java.sql.SQLException; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import co.mitro.core.crypto.KeyInterfaces.CryptoError; import co.mitro.core.crypto.KeyInterfaces.KeyFactory; import co.mitro.core.crypto.KeyInterfaces.PublicKeyInterface; import co.mitro.core.exceptions.InvalidRequestException; import co.mitro.core.exceptions.MitroServletException; import co.mitro.core.exceptions.PermissionException; import co.mitro.core.server.Manager; import co.mitro.core.server.data.DBAcl; import co.mitro.core.server.data.DBAcl.AccessLevelType; import co.mitro.core.server.data.DBGroup; import co.mitro.core.server.data.DBGroupSecret; import co.mitro.core.server.data.DBIdentity; import co.mitro.core.server.data.DBServerVisibleSecret; import co.mitro.core.server.data.RPC.SignedRequest; import co.mitro.core.servlets.ListMySecretsAndGroupKeys.AdminAccess; import com.google.common.base.Preconditions; import com.google.common.collect.ImmutableList; import com.google.common.collect.Sets; import com.j256.ormlite.stmt.QueryBuilder; import com.j256.ormlite.stmt.Where; /** * Provides access to data at the access level of the user making the request. This is intended * to be the single location where all access control checks will be enforced. This code must * be reviewed carefully, but it should be very difficult for callers to introduce access control * errors. */ public class AuthenticatedDB { private final Manager manager; private final DBIdentity identity; private AuthenticatedDB(Manager manager, DBIdentity identity) { Preconditions.checkNotNull(manager); Preconditions.checkNotNull(identity); this.manager = manager; this.identity = identity; } /** Returns a new AuthenticatedDb, if the request is valid. */ public static AuthenticatedDB newFromRequest( Manager manager, KeyFactory keyFactory, SignedRequest request) throws SQLException, MitroServletException { if (request.identity == null) { return null; } DBIdentity identity = DBIdentity.getIdentityForUserName(manager, request.identity); if (identity == null) { return null; } // the identity exists: check the request! try { PublicKeyInterface key = keyFactory.loadPublicKey(identity.getPublicKeyString()); if (!key.verify(request.request, request.signature)) { throw new MitroServletException( "failed to verify signature for identity " + identity.getName()); } } catch (CryptoError e) { throw new MitroServletException("failed to load key for identity " + identity.getName(), e); } return new AuthenticatedDB(manager, identity); } /** Hack for compatibility with existing code. We should only use {@link #newFromRequest}. */ @Deprecated public static AuthenticatedDB deprecatedNew( Manager manager, DBIdentity identity) { return new AuthenticatedDB(manager, identity); } public boolean isOrganizationAdmin(int orgId) throws MitroServletException { try { DBGroup group = getDirectlyAccessibleGroup(orgId); if (null == group) { return false; } assert (group.isTopLevelOrganization()); return true; } catch (SQLException e) { throw new MitroServletException(e); } } public DBGroup getOrganizationAsAdmin(int orgId) throws MitroServletException { DBGroup orgGroup; try { orgGroup = getDirectlyAccessibleGroup(orgId); } catch (SQLException e) { throw new MitroServletException(e); } if (null == orgGroup || !orgGroup.isTopLevelOrganization()) { throw new MitroServletException("Not org or no access"); } return orgGroup; } /** * Returns the DBGroup for orgId if it is an organization and the user is a member. * @throws MitroServletException if orgId does not exist, is not an organization, or the user * is not a member. */ public DBGroup getOrganizationAsMember(int orgId) throws MitroServletException, SQLException { Set<DBGroup> orgs = getOrganizations(); for (DBGroup org : orgs) { if (org.getId() == orgId) { return org; } } // TODO: Should this and getOrganizationAsAdmin return null on error instead? throw new MitroServletException("Not org or no access"); } public DBGroup getGroupAsUserOrOrgAdmin(int groupId) throws SQLException, MitroServletException { final DBGroup rval = getDirectlyAccessibleGroup(groupId); if (rval != null) { return rval; } // try to get the group indirectly. final DBGroup actualGroup = manager.groupDao.queryForId(groupId); for (DBAcl acl : actualGroup.getAcls()) { Integer memberGroup = acl.getMemberGroupIdAsInteger(); if (memberGroup != null) { if (isOrganizationAdmin(memberGroup)) { return actualGroup; } } } return null; } public DBGroup getGroupForAddSecret(int groupId) throws SQLException, MitroServletException { // if we are in the group: we can add to the group try { return getGroupOrOrg(groupId); } catch (PermissionException pe) { // check if we have access as an admin of the organization this group // we might still get access to this secret due to being a member of an org that this group belongs to. // This works for org admins as well. DBGroup rval = manager.groupDao.queryForId(groupId); Set<DBGroup> orgs = this.getOrganizations(); Set<Integer> orgIds = Sets.newHashSet(); for (DBGroup org: orgs) { orgIds.add(org.getId()); } if (rval != null) { for (DBAcl acl : rval.getAcls()) { if (orgIds.contains(acl.getMemberGroupIdAsInteger())) { return rval; } } // permit access if requestor is an admin of a SECRET in this group, so they can edit it. // TODO: Add an EditSecret API and forbid access if requestor is not in group! // find all secrets in group boolean isAdminForSecretInGroup = false; boolean isOrgAdminForSecretInGroup = false; for (DBGroupSecret secretInGroup : rval.getGroupSecrets()) { DBServerVisibleSecret secret = secretInGroup.getServerVisibleSecret(); // test secret to see if requestor can edit it AccessLevelType accessLevel = identity.getHighestAccessLevel(manager, secret); if (accessLevel != null && accessLevel.canEditSecret()) { // the requestor is an editor for secret, which is in group isAdminForSecretInGroup = true; break; } else { if (null != getSecretAsOrgAdmin(secret.getId())) { isOrgAdminForSecretInGroup = true; break; } } } if (isAdminForSecretInGroup || isOrgAdminForSecretInGroup) { return rval; } } throw new MitroServletException("User should not be able to see group: " + groupId, pe); } } /** * * This returns a group OR org that a user has access to. The group is returned * IFF (the user is a member of the group) OR (the group is a top-level org of which the user is a member) * @param groupId group id * @return the group if it's accessible and it exists * @throws PermissionException the user does not have the ability to access the org or the org does not exist. */ public DBGroup getGroupOrOrg(int groupId) throws PermissionException, SQLException { DBGroup orgGroup = getDirectlyAccessibleGroup(groupId); if (null != orgGroup) { // direct membership return orgGroup; } orgGroup = manager.groupDao.queryForId(groupId); if (orgGroup != null && orgGroup.isTopLevelOrganization() && getOrganizations().contains(orgGroup)) { // access via org membership return orgGroup; } throw new PermissionException("group does not exist or user not in group or not member of org (" + groupId + ")"); } /** Returns the group represented by groupId, if this identity has direct access to it. */ private DBGroup getDirectlyAccessibleGroup(int groupId) throws SQLException { // Query for acls referencing (groupId, requesting identity) List<DBGroup> groups = getDirectlyAccessibleGroups(ImmutableList.of(groupId)); assert(groups.size() == 0 || groups.size() == 1); if (groups.size() == 0) { return null; } else { assert groups.size() == 1; assert groups.get(0) != null; return groups.get(0); } } /** * Saves a new group with new acls, if this user has permission and the group and ACLs * are valid. This will throw an exception if the group and ACLs already exist. */ public void saveNewGroupWithAcls(DBGroup group, List<DBAcl> acls) throws InvalidRequestException, SQLException { // verify that the acls actually apply correctly to group validateGroupAcls(group, acls); // The requestor must be able to edit secrets in the new group Set<Integer> groupAdmins = getFirstLevelUsers(acls, DBAcl.modifyGroupSecretsAccess()); if (!groupAdmins.contains(identity.getId())) { // We cannot allow a group to be created where the requestor cannot modify // group secrets. throw new InvalidRequestException("Cannot create group where requestor " + identity.getName() + " does not have permission to modify group secrets"); } // save the group and the acls manager.groupDao.create(group); for (DBAcl acl : acls) { manager.aclDao.create(acl); } } /** Returns the organizations the requestor can access. */ public Set<DBGroup> getOrganizations() throws SQLException { HashSet<DBGroup> organizations = new HashSet<>(); for (DBGroup group : getAllDirectlyAccessibleGroups()) { if (group.isPrivateUserGroup()) { // private groups must either be owned by only the user or belong to an organization for (DBAcl acl : group.getAcls()) { assert DBAcl.modifyGroupSecretsAccess().contains(acl.getLevel()); if (acl.getMemberGroupId() != null) { // any group members of private groups must be organizations DBGroup organization = acl.loadMemberGroup(manager.groupDao); assert organization.isTopLevelOrganization(); organizations.add(organization); } else { assert acl.getMemberIdentityId().getId() == identity.getId(); } } } } return organizations; } public DBIdentity getIdentity(String name) throws SQLException { // TODO: Move this method here? return DBIdentity.getIdentityForUserName(manager, name); } /** Returns the secret if the requestor has regular user access. */ public DBServerVisibleSecret getSecretAsUser(int secretId) throws SQLException { return getSecretAsUser(secretId, DBAcl.allAccessTypes()); } public DBServerVisibleSecret getSecretAsUser(int secretId, Collection<DBAcl.AccessLevelType> accessLevels) throws SQLException { DBServerVisibleSecret rval = manager.svsDao.queryForId(secretId); if (rval == null) { return rval; } // check permissions. if (rval.getAllUserIdsWithAccess(manager, accessLevels, AdminAccess.IGNORE_ACCESS_VIA_TOPLEVEL_GROUPS).contains(this.identity.getId())) { return rval; } return null; } public DBServerVisibleSecret getSecretAsUserOrAdmin(int secretId, Collection<DBAcl.AccessLevelType> accessLevels) throws SQLException { DBServerVisibleSecret rval = getSecretAsUser(secretId, accessLevels); if (rval == null) { rval = getSecretAsOrgAdmin(secretId); } return rval; } /** Returns the secret if the requestor has admin access. */ public DBServerVisibleSecret getSecretAsOrgAdmin(int secretId) throws SQLException { Set<DBGroup> orgs = getOrganizations(); if (orgs.isEmpty()) { return null; } Set<Integer> groupIds = Sets.newHashSet(); for (DBGroup org : orgs) { org = getDirectlyAccessibleGroup(org.getId()); if (org == null) { // you are not an org admin continue; } groupIds.add(org.getId()); for (DBGroup g : org.getAllOrgGroups(manager)) { groupIds.add(g.getId()); } } if (groupIds.isEmpty()) { return null; } // if the secret is shared with any of the groups, return it List<DBGroupSecret> groupSecrets = manager.groupSecretDao.queryBuilder().where().eq(DBGroupSecret.SVS_ID_NAME, secretId) .and().in(DBGroupSecret.GROUP_ID_NAME, groupIds).query(); if (groupSecrets.isEmpty()) { return null; } // any access path will do. return groupSecrets.get(0).getServerVisibleSecret(); } /** * Throws an exception if the ACL list is not valid: * - must not be empty * - must apply to targetGroup. * - members must be an identity OR a group, not both. * - identities must already exist in the database. * - groups must be accessible by this user and exist. * - no duplicate users or groups */ private void validateGroupAcls(DBGroup targetGroup, List<DBAcl> acls) throws InvalidRequestException, SQLException { if (acls.isEmpty()) { throw new InvalidRequestException("acls must not be empty"); } HashSet<Integer> uniqueMemberIdentities = new HashSet<>(); HashSet<Integer> uniqueMemberGroups = new HashSet<>(); for (DBAcl acl : acls) { // must apply to targetGroup if (acl.getGroupId().getId() != targetGroup.getId()) { throw new InvalidRequestException("acl applies to the wrong group"); } boolean isIdentityAcl = acl.getMemberIdentityId() != null; boolean isGroupAcl = acl.getMemberGroupId() != null; if (!(isIdentityAcl ^ isGroupAcl)) { throw new InvalidRequestException("acl must apply to exactly one identity or group; " + "specifies identity: " + isIdentityAcl + "; specifies group: " + isGroupAcl); } if (isIdentityAcl) { assert !isGroupAcl; if (!uniqueMemberIdentities.add(acl.getMemberIdentityId().getId())) { throw new InvalidRequestException( "duplicate identity: " + acl.getMemberIdentityId().getId()); } } else { assert !isIdentityAcl; if (!uniqueMemberGroups.add(acl.getMemberGroupId().getId())) { throw new InvalidRequestException("duplicate group: " + acl.getMemberGroupId().getId()); } } } if (!uniqueMemberIdentities.isEmpty()) { // verify all referenced identities exist if (DBIdentity.getUserNamesFromIds(manager, uniqueMemberIdentities).size() != uniqueMemberIdentities.size()) { throw new InvalidRequestException("some identities do not exist"); } } if (!uniqueMemberGroups.isEmpty()) { // verifies all referenced groups are accessible QueryBuilder<DBGroup, Integer> groupQuery = getDirectlyAccessibleGroupsQuery(uniqueMemberGroups); if (groupQuery.countOf() != uniqueMemberGroups.size()) { throw new InvalidRequestException("some groups are not accessible"); } } } /** Returns all groups where requestor is a direct member. */ private List<DBGroup> getAllDirectlyAccessibleGroups() throws SQLException { QueryBuilder<DBGroup, Integer> groupQuery = getDirectlyAccessibleGroupsQuery(null); return groupQuery.query(); } /** Returns the DBGroups for groupIds if they are directly accessible (at any level). */ private List<DBGroup> getDirectlyAccessibleGroups(Collection<Integer> groupIds) throws SQLException { if (groupIds.isEmpty()) { // Postgres can't parse empty IN: "columnName" IN () return Collections.emptyList(); } QueryBuilder<DBGroup, Integer> groupQuery = getDirectlyAccessibleGroupsQuery(groupIds); return manager.groupDao.query(groupQuery.prepare()); } private QueryBuilder<DBGroup, Integer> getDirectlyAccessibleGroupsQuery( Collection<Integer> optionalGroupIdFilter) throws SQLException { return getDirectlyAccessibleGroupsQuery(manager, optionalGroupIdFilter, identity.getId(), null); } /** * Returns a query for groups where requestor is a direct member, optionally filtered to * only groups in optionalGroupIdFilter. */ public static QueryBuilder<DBGroup, Integer> getDirectlyAccessibleGroupsQuery( Manager manager, Collection<Integer> optionalGroupIdFilter, Integer memberIdentityId, Integer memberGroupId) throws SQLException { assert (null == memberGroupId ^ null == memberIdentityId) : "set either identity or group but not both"; // get ACLs matching (group=*, member=requesting identity) QueryBuilder<DBAcl, Integer> aclQuery = manager.aclDao.queryBuilder(); Where<DBAcl, Integer> whereClause = null; if (memberIdentityId != null) { whereClause = aclQuery.where().eq(DBAcl.MEMBER_IDENTITY_FIELD_NAME, memberIdentityId); } else { whereClause = aclQuery.where().eq(DBAcl.MEMBER_GROUP_FIELD_NAME, memberGroupId); } if (optionalGroupIdFilter != null) { // get ACLs matching (group_id in optionalGroupIdFilter, member_id=requesting identity) // Postgres/SQL doesn't support empty IN queries assert !optionalGroupIdFilter.isEmpty(); whereClause.and().in(DBAcl.GROUP_ID_FIELD_NAME, optionalGroupIdFilter); } // return the actual groups themselves // NOTE: OrmLite's .join() just takes the "first" field, which is fragile! aclQuery.selectColumns(DBAcl.GROUP_ID_FIELD_NAME); QueryBuilder<DBGroup, Integer> groupQuery = manager.groupDao.queryBuilder(); groupQuery.where().in(DBGroup.ID_FIELD_NAME, aclQuery); return groupQuery; } /** * Returns users included in acls at a level in includedAccessLevels, loading one level of group * members. Throws if the requestor is not a member of one of the referenced groups. */ private Set<Integer> getFirstLevelUsers(List<DBAcl> acls, Set<AccessLevelType> includedAccessLevels) throws SQLException, InvalidRequestException { HashSet<Integer> users = new HashSet<Integer>(); HashSet<Integer> groups = new HashSet<Integer>(); for (DBAcl acl : acls) { if (includedAccessLevels.contains(acl.getLevel())) { if (null != acl.getMemberIdentityId()) { users.add(acl.getMemberIdentityId().getId()); } else { // must be a member group groups.add(acl.getMemberGroupId().getId()); } } } // get users from all accessible groups List<DBGroup> accessibleGroups = getDirectlyAccessibleGroups(groups); if (accessibleGroups.size() != groups.size()) { throw new InvalidRequestException("not all groups are accessible"); } for (DBGroup accessibleGroup : accessibleGroups) { accessibleGroup.putDirectUsersIntoSet(users, includedAccessLevels); } return users; } public DBServerVisibleSecret getServerSecretForViewOrEdit(int secretId) throws SQLException, MitroServletException { DBServerVisibleSecret svs = getSecretAsOrgAdmin(secretId); if (svs == null) { return null; } // we may have been granted access to the secret via a non-admin path. // only return the secret if we have access via org admin only. for (DBGroupSecret gs : svs.getGroupSecrets()) { DBGroup group = gs.getGroup(); manager.groupDao.refresh(group); if (group.isTopLevelOrganization() && isOrganizationAdmin(group.getId())) { return svs; } } return null; } }