cockpit/pkg/kubernetes/scripts/policy.js

413 lines
18 KiB
JavaScript

/*
* This file is part of Cockpit.
*
* Copyright (C) 2016 Red Hat, Inc.
*
* Cockpit is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation; either version 2.1 of the License, or
* (at your option) any later version.
*
* Cockpit 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
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Cockpit; If not, see <http://www.gnu.org/licenses/>.
*/
(function() {
var angular = require('angular');
require('./kube-client');
angular.module('registry.policy', [
'kubeClient',
])
.factory("projectPolicy", [
'$q',
'$rootScope',
'kubeLoader',
'kubeMethods',
'kubeSelect',
'KubeWatch',
'KubeRequest',
'KUBE_SCHEMA',
function($q, $rootScope, loader, methods, select, watch, KubeRequest, KUBE_SCHEMA) {
var apiGroup;
var RBAC_GROUP = "rbac.authorization.k8s.io";
var RBAC_API = "/apis/rbac.authorization.k8s.io/v1beta1";
var POLICY_BINDING_API = KUBE_SCHEMA["RoleBinding"]["api"];
var watchPromise;
function setupRoleBinding(group) {
KUBE_SCHEMA["RoleBinding"]["api"] = group ? RBAC_API : POLICY_BINDING_API;
KUBE_SCHEMA["rolebindings"]["api"] = group ? RBAC_API : POLICY_BINDING_API;
apiGroup = group;
expireSAR(null);
expireWhoCan(null);
return group ? "rolebindings" : "policybindings";
}
function ensureWatchType() {
if (!watchPromise) {
watchPromise = new KubeRequest("GET", "/oapi/v1")
.then(function(response) {
var data = response.data || {};
var i;
var l = data.resources || [];
for (i = 0; i < l.length; i++) {
if (l[i].kind == "PolicyBinding")
return setupRoleBinding();
}
return setupRoleBinding(RBAC_GROUP);
}, function(err) {
console.warn("Error getting API", err);
return setupRoleBinding();
});
}
return watchPromise;
}
/*
* Data loading hacks:
*
* We would like to watch rolebindings, but not all versions support
* that. So we have to watch policybindings and then infer the
* rolebindings from there.
*
* In addition we would like to be able to load User and Group objects,
* even if only for a certain project. However, non-cluster admins
* fail to do this, so we simulate these objects from the role bindings.
*/
loader.listen(function(present, removed) {
var link;
var expire = { };
/* If reseting clear status */
if (!present && !removed) {
expireSAR(null);
expireWhoCan(null);
watchPromise = null;
return;
}
for (link in removed) {
if (removed[link].kind == "PolicyBinding") {
update_rolebindings(removed[link].roleBindings, true);
expire[removed[link].metadata.namespace] = true;
}
}
for (link in present) {
if (present[link].kind == "PolicyBinding") {
update_rolebindings(present[link].roleBindings, false);
expire[present[link].metadata.namespace] = true;
} else if (present[link].kind == "RoleBinding") {
ensure_subjects(present[link].subjects || []);
expire[present[link].metadata.namespace] = true;
}
}
var namespace;
for (namespace in expire) {
expireWhoCan(namespace);
expireSAR(namespace);
}
});
function update_rolebindings(bindings, removed) {
angular.forEach(bindings || [], function(wrapper) {
loader.handle(wrapper.roleBinding, removed, "RoleBinding");
});
}
function ensure_subjects(subjects) {
angular.forEach(subjects, function(subject) {
var link = loader.resolve(subject.kind, subject.name, subject.namespace);
if (link in loader.objects)
return;
/* Don't show system groups */
if (subject.kind == "Group" && subject.name.indexOf("system:") === 0)
return;
/* An interim object, until perhaps the real thing can be loaded */
var interim = { kind: subject.kind, apiVersion: "v1", metadata: { name: subject.name } };
if (subject.namespace)
interim.metadata.namespace = subject.namespace;
loader.handle(interim);
});
}
/*
* Cached localresourceaccessreviews responses, and expired data
* Each one has a project key, containing an object with verb:resource keys.
*/
var cached = { };
var expired = { };
function fillWhoCan(namespace, verb, resource, result) {
var key = verb + ":" + resource;
if (!(namespace in cached))
cached[namespace] = { };
cached[namespace][key] = result;
if (result) {
if (namespace in expired) {
delete expired[namespace][key];
}
}
$rootScope.$applyAsync();
}
function expireWhoCan(namespace) {
if (namespace) {
expired[namespace] = angular.extend({ }, cached[namespace]);
delete cached[namespace];
} else {
expired = cached;
cached = { };
}
$rootScope.$applyAsync();
}
function lookupWhoCan(namespace, verb, resource) {
var key = verb + ":" + resource;
var ask = true;
var result = null;
var data = cached[namespace];
if (data) {
if (key in data) {
result = data[key];
ask = false;
}
}
if (!result) {
data = expired[namespace];
if (data) {
if (key in data)
result = data[key];
}
}
if (!ask)
return result;
/* Perform a request */
var request = {
kind: "LocalResourceAccessReview",
apiVersion: "v1",
namespace: "",
verb: verb,
resource: resource,
resourceName: "",
content: null
};
/* Fill in null info while looking up */
fillWhoCan(namespace, verb, resource, null);
var path = loader.resolve("localresourceaccessreviews", null, namespace);
methods.post(path, request)
.then(function(response) {
fillWhoCan(namespace, verb, resource, response);
}, function(response) {
console.warn("failed to lookup access:", namespace, verb, resource + ":",
response.message || JSON.stringify(response));
});
return result;
}
var sarCache = { };
function subjectAccessReview(namespace, user, verb, resource) {
var key = namespace + ':' + (user ? user.metadata.name : "") + ':' + verb + ':' + resource;
var defer = $q.defer();
if (key in sarCache) {
defer.resolve(sarCache[key]);
} else {
var request = {
kind: "SubjectAccessReview",
apiVersion: "v1",
namespace: namespace,
verb: verb,
resource: resource
};
methods.post(loader.resolve("subjectaccessreviews"), request)
.then(function(response) {
sarCache[key] = response.allowed;
defer.resolve(response.allowed);
}, function(response) {
console.warn("failed to review subject access:", response.message || JSON.stringify(response));
defer.reject(response.message || JSON.stringify(response));
});
}
return defer.promise;
}
function expireSAR(namespace) {
if (namespace) {
for (var key in sarCache) {
if (key.lastIndexOf(namespace + ':', 0) === 0)
delete sarCache[key];
}
} else {
sarCache = { };
}
$rootScope.$applyAsync();
}
/*
* HACK: There's no way to PATCH subjects in or out
* of a role, so we have to use this race prone mechanism.
*/
function modifyRole(namespace, role, callback) {
var path = loader.resolve("RoleBinding", role, namespace);
return loader.load(path)
.then(function(resource) {
callback(resource);
return methods.put(path, resource);
});
}
function createRole(namespace, role, subjects) {
var name = toName(role);
var binding = {
kind: "RoleBinding",
metadata: {
name: name,
namespace: namespace,
creationTimestamp: null,
},
userNames: [],
groupNames: [],
subjects: [],
roleRef: {
name: role,
kind: "ClusterRole",
}
};
addToArray(roleArray(binding, "subjects"), subjects);
addToArray(roleArrayKind(binding, subjects.kind), subjects.name);
return methods.create(binding, namespace);
}
function removeFromRole(project, role, subject) {
subject.apiGroup = apiGroup;
var namespace = toName(project);
return modifyRole(namespace, role, function(data) {
removeFromArray(roleArray(data, "subjects"), subject);
removeFromArray(roleArrayKind(data, subject.kind), subject.name);
}).then(function() {
expireWhoCan(namespace);
}, function(resp) {
/* If the role doesn't exist consider removed to work */
if (resp.code !== 404)
return $q.reject(resp);
});
}
function removeMemberFromProject(project, subjectRoleBindings, subject) {
var registryRoles = ["registry-admin", "registry-editor", "registry-viewer"];
var chain = $q.when();
subject.apiGroup = apiGroup;
angular.forEach(subjectRoleBindings, function(role) {
// Since we only added registry roles
// remove ONLY registry roles
if (indexOf(registryRoles, role.roleRef.name) !== -1) {
chain = chain.then(function() {
return removeFromRole(project, role.roleRef.name, subject);
});
}
});
return chain;
}
function indexOf(array, value) {
var i, len;
for (i = 0, len = array.length; i < len; i++) {
if (angular.equals(array[i], value))
return i;
}
return -1;
}
function addToArray(array, value) {
var index = indexOf(array, value);
if (index < 0)
array.push(value);
}
function removeFromArray(array, value) {
var index = indexOf(array, value);
if (index >= 0)
array.splice(index, 1);
}
function roleArray(data, field) {
var array = data[field] || [];
data[field] = array;
return array;
}
function roleArrayKind(data, kind) {
if (kind == "Group" || kind == "SystemGroup")
return roleArray(data, "groupNames");
else
return roleArray(data, "userNames");
}
function toName(object) {
if (typeof object == "object")
return object.metadata.name;
else
return object;
}
return {
watch: function watch(until) {
ensureWatchType().then(function (what) {
loader.watch(what, until)
.then(function() {
expireWhoCan(null);
});
});
},
whoCan: function whoCan(project, verb, resource) {
return lookupWhoCan(toName(project), verb, resource);
},
addToRole: function addToRole(project, role, subject) {
subject.apiGroup = apiGroup;
var namespace = toName(project);
return modifyRole(namespace, role, function(data) {
addToArray(roleArray(data, "subjects"), subject);
addToArray(roleArrayKind(data, subject.kind), subject.name);
}).then(function() {
expireWhoCan(namespace);
}, function(resp) {
/* If the role doesn't exist create it */
if (resp.code === 404)
return createRole(namespace, role, subject);
return $q.reject(resp);
});
},
removeFromRole: removeFromRole,
removeMemberFromProject: removeMemberFromProject,
subjectAccessReview: subjectAccessReview
};
}
]);
}());