diff --git a/.meteor/packages b/.meteor/packages
index cca744ee60f7..072c1ba6056d 100644
--- a/.meteor/packages
+++ b/.meteor/packages
@@ -15,6 +15,7 @@ jquery
less
meteor-platform
reactive-var
+reactive-dict
service-configuration
chrismbeckett:toastr
diff --git a/client/lib/chatMessages.coffee b/client/lib/chatMessages.coffee
index 682b0e97d691..4e21b41fe5ac 100644
--- a/client/lib/chatMessages.coffee
+++ b/client/lib/chatMessages.coffee
@@ -19,7 +19,7 @@
# self.scrollable
resize = ->
- dif = 60 + $(".messages-container").find("footer").outerHeight()
+ dif = 60 + $(".messages-container").find("footer").outerHeight() + $(".messages-container").find(".security-banner").outerHeight()
$(".messages-box").css
height: "calc(100% - #{dif}px)"
diff --git a/client/stylesheets/base.less b/client/stylesheets/base.less
index b6627ddd9e26..2cac4bc7a51d 100644
--- a/client/stylesheets/base.less
+++ b/client/stylesheets/base.less
@@ -1748,11 +1748,25 @@ a.github-fork {
color: @primary-font-color;
}
}
+ .security-banner.U { background-color: @unclassified-background-color; }
+ .security-banner.C { background-color: @confidential-background-color; }
+ .security-banner.S { background-color: @secret-background-color; }
+ .security-banner.TS { background-color: @top-secret-background-color; }
+ .security-banner {
+ position: relative;
+ margin: 60px 20px 0px 0px;
+ padding: 5px 0px 5px 0px;
+ width: 100%;
+ text-align: center;
+ /* default */
+ color:white;
+ background-color: @unclassified-background-color;
+ }
.wrapper {
position: absolute;
width: 100%;
- height: 100%;
- top: 0;
+ height: 100%;
+ top: 0;
left: 0;
overflow-y: auto;
overflow-x: hidden;
@@ -1882,10 +1896,10 @@ a.github-fork {
.messages-box {
position: relative;
- margin: 60px 20px 0px 0px;
+ margin: 0px 20px 0px 0px;
overflow: hidden;
width: 100%;
- .calc(height, ~'100% - 120px');
+ .calc(height, ~'100% - 155px');
ul {
padding: 21px 0 10px;
}
diff --git a/client/stylesheets/global/_variables.less b/client/stylesheets/global/_variables.less
index ddadf7606b91..53b9c01889b5 100644
--- a/client/stylesheets/global/_variables.less
+++ b/client/stylesheets/global/_variables.less
@@ -16,6 +16,11 @@
@secondary-background-color: #F4F4F4;
@tertiary-background-color: #EAEAEA;
+@unclassified-background-color: green;
+@confidential-background-color: blue;
+@secret-background-color: red;
+@top-secret-background-color: orange;
+
@link-font-color: #008CE3;
@primary-font-color: #444444;
diff --git a/client/views/app/room.coffee b/client/views/app/room.coffee
index b659463e3594..fb9ba35da30a 100644
--- a/client/views/app/room.coffee
+++ b/client/views/app/room.coffee
@@ -283,6 +283,28 @@ Template.room.helpers
noRtcLayout: ->
return (!Session.get('rtcLayoutmode') || (Session.get('rtcLayoutmode') == 0) ? true: false);
+ bannerData: ->
+ # The data context only contains the room id. one way to get the banner data is to just pass
+ # this id to a server-side method and let it look up the room details (such as permissions)
+ # and then return the banner info.
+ #
+ # HOWEVER, doing it this way does not allow the banner to be reactive in case the underlying
+ # room data changes (eg, if someone edits Mongo manually). This is because the template has
+ # no way of knowing if anything changed, so the method never gets called again. One way around
+ # this is to make "bannerData" itself reactive by having it depend directly on the room data.
+ # Then, since that data gets synchronized with the server, the template will be reprocessed
+ # when the data changes.
+ accessPermissions = ChatRoom.findOne(this._id)?.accessPermissions || []
+ Template.instance().updateBannerData(accessPermissions)
+ return Template.instance().bannerData
+
+ # For helpers "classificationId" and "securityBannerText", "this" refers to what is returned
+ # from "bannerData"
+ classificationId: ->
+ return this.get 'classificationId'
+
+ securityBannerText: ->
+ return this.get 'text'
Template.room.events
@@ -528,10 +550,24 @@ Template.room.events
Template.room.onCreated ->
console.log 'room.onCreated' if window.rocketDebug
+ self = this
# this.scrollOnBottom = true
this.showUsersOffline = new ReactiveVar false
this.atBottom = true
+ this.bannerData = new ReactiveDict
+ this.bannerData.set 'text', 'Unknown'
+ this.bannerData.set 'classificationId', 'U'
+
+ this.updateBannerData = (accessPermissions) ->
+ Meteor.call 'getSecurityBanner', accessPermissions, (error, result) ->
+ if error
+ toastr.error error.reason
+ else
+ self.bannerData.set 'text', result.text
+ self.bannerData.set 'classificationId', result.classificationId
+
+
Template.room.onRendered ->
console.log 'room.onRendered' if window.rocketDebug
FlexTab.check()
diff --git a/client/views/app/room.html b/client/views/app/room.html
index 3cd8e3c08922..7d8f4dace5f9 100644
--- a/client/views/app/room.html
+++ b/client/views/app/room.html
@@ -19,6 +19,11 @@
{{/if}}
+ {{#with bannerData}}
+
+ {{securityBannerText}}
+
+ {{/with}}
diff --git a/server/lib/accessPermission.js b/server/lib/accessPermission.js
new file mode 100644
index 000000000000..2fe5c87c548c
--- /dev/null
+++ b/server/lib/accessPermission.js
@@ -0,0 +1,158 @@
+Jedis = this.Jedis || {};
+// Class for managing user/resource permissions.
+// Structure: Hash of arrays of AccessPermission objects (see Schemas.AccessPermission), keyed by "type", which will be
+// one of the following:
+// classification
+// SAP
+// SCI
+// Release Caveat
+// Example:
+// {
+// "classification": ["TS", "S", "C", "U"],
+// "SAP: [
+// { "_id" : "107", "trigraph" : "QUE", "label" : "Quesadilla", "type" : "SAP" },
+// { "_id" : "108", "trigraph" : "HAB", "label" : "Habanero", "type" : "SAP" }
+// ]
+// }
+//
+
+// Construct an AccessPermission object from a list of access ids.
+Jedis.AccessPermission = function(ids) {
+ if (!(this instanceof arguments.callee)) {
+ // We were called without `new' operator.
+ return new arguments.callee(arguments);
+ }
+ // Allow any of the supported input types to be passed as scalar.
+ if (!_.isArray(ids)) {
+ ids = [ids];
+ }
+ // Now we have array, but of what? String id or Schema.AccessPermission objects?
+ // Assumption: Whichever it is, list should be homogeneous.
+ if (!ids.length) {
+ // Empty object
+ return this;
+ }
+ // Ensure we have a list of objects for grouping stage.
+ var perms = typeof ids[0] === 'object'
+ ? ids
+ : AccessPermissions.find({_id: {$in : ids}}).fetch();
+
+ // Group by types.
+ perms.reduce(function(o, perm) {
+ var type = perm.type;
+ o[type] = o[type] || [];
+ o[type].push(perm);
+ return o;
+ }, this);
+};
+//
+Jedis.AccessPermission.prototype.resourceClassifications = function() {
+ var classInfo = { selected:null, higher:[], lower:[] };
+ var classifications = Jedis.accessManager.getClassifications();
+ var classificationIds = _.pluck(classifications, '_id');
+ // Get the classification from the access permissions
+ var resourceClassificationIds = _.filter(_.pluck(this.classification,'_id'), function(id) {
+ return _.contains(classificationIds ,id);
+ });
+ if (resourceClassificationIds.length > 1) {
+ console.warn('Resource permissions has more then one classifications' + resourceClassificationIds.length )
+ }
+ var resourceClassificationId = resourceClassificationIds[0];
+ _.each(classifications, function(element, index, list) {
+ var cid = element._id;
+ if (cid === resourceClassificationId) {
+ classInfo.selected = cid;
+ } else if ( ! classInfo.selected) {
+ classInfo.higher.push(cid);
+ } else {
+ classInfo.lower.push(cid);
+ }
+ });
+ return classInfo;
+};
+// Return a flat list of ids whose types are in the provided list (default all)
+// Design Intent: The object instance maintains the full access permission object, but in some scenarios (e.g., calls to
+// external validation service), we may need only the ids, possibly only the ids for specific types.
+Jedis.AccessPermission.prototype.getPermissionIds = function(types) {
+ var self = this;
+ if (typeof types === 'string') {
+ types = [types];
+ }
+ // Default (no types specified) means all types defined for this instance.
+ types = types || _.keys(self);
+ return types.reduce(function(acc, type) {
+ return self[type] ? acc.concat(_.pluck(self[type], '_id')) : acc;
+ }, []);
+};
+
+// Return true iff invocant has sufficient permissions to access input resource.
+// TODO - Consider pros/cons with strategy vs instance method approach. First let's implement it all within the class.
+Jedis.AccessPermission.prototype.canAccessResource = function(resPerms) {
+ var andTypes = ['SCI', 'SAP', 'classification'],
+ orTypes = ['Release Caveat'];
+ //console.log("canAccessResource: user perms: ", this.toString());
+ //console.log("canAccessResource: resource perms: ", resPerms.toString());
+
+ var userIds = this.getPermissionIds(andTypes);
+ // Note: The following will short-circuit on failure.
+ var fail =
+ // AND logic
+ resPerms.getPermissionIds(andTypes).some(function(resId) {
+ return userIds.indexOf(resId) === -1;
+ });
+ if (!fail) {
+ // OR logic
+ var resIds = resPerms.getPermissionIds(orTypes);
+ userIds = this.getPermissionIds(orTypes);
+ if (resIds.length) {
+ fail = resIds.every(function(resId) {
+ return userIds.indexOf(resId) === -1;
+ });
+ }
+ }
+ //console.log("canAccessResource says: ", fail ? "fail" : "pass");
+ return !fail;
+};
+
+// Convert hash of lists keyed by type to flat list.
+Jedis.AccessPermission.prototype.toArray = function() {
+ return _.values(this).reduce(function(acc, perms) {
+ return acc.concat(perms);
+ }, []);
+};
+
+// --- Debug/Test methods ---
+// Add permission object(s) represented by input id(s).
+Jedis.AccessPermission.prototype.addAccessIds = function(ids) {
+ ids = _.isArray(ids) ? ids : [ids];
+ // Lookup input ids and add corresponding objects under applicable keys (if not already there).
+ _.pairs(
+ _.groupBy(
+ AccessPermissions.find({_id: {$in: ids}}).fetch(),
+ function(perm) { return perm.type }))
+ // Iterate [type, perm_ary] pairs.
+ .forEach(function(pair) {
+ var type = pair[0], perms = pair[1];
+ this[type] = this[type] || [];
+ // Merge the (unique) new access objects.
+ this[type] = _.uniq(this[type].concat(perms),
+ function(perm) { return perm._id })
+ }, this);
+};
+
+// Remove permission object(s) represented by input id(s).
+Jedis.AccessPermission.prototype.removeAccessIds = function(ids) {
+ ids = _.isArray(ids) ? ids : [ids];
+ // Remove access objects (from under their respective keys) whose id is found in input list.
+ // Iterate access types (object's own enumerable properties)
+ // Idiosyncrasy: Underscore docs say mapObject, but map is actually overloaded.
+ _.map(this, function(perms, type) {
+ this[type] = perms.filter(function(perm) { return ids.indexOf(perm._id) === -1 });
+ }, this);
+};
+
+Jedis.AccessPermission.prototype.toString = function() {
+ return JSON.stringify(this, undefined, 4);
+};
+
+// vim:ts=4:sw=4:tw=120
diff --git a/server/methods/getSecurityBanner.coffee b/server/methods/getSecurityBanner.coffee
new file mode 100644
index 000000000000..52bfe898375f
--- /dev/null
+++ b/server/methods/getSecurityBanner.coffee
@@ -0,0 +1,65 @@
+Meteor.methods
+ getSecurityBanner: (permissionIds) ->
+ if not Meteor.userId()
+ throw new Meteor.Error('invalid-user', "[methods] getSecurityBanner -> Invalid user")
+
+ banner = {}
+
+ perms = new Jedis.AccessPermission permissionIds
+ .toArray();
+
+ systemCountryCode = Jedis.accessManager.getPermissions(Jedis.settings.get('public').system.countryCode)
+
+ if systemCountryCode.length is 0
+ console.log 'System country not found. Defaulting to USA'
+ systemCountryCode = _id: '300', trigraph: 'USA', label: 'United States', type: 'Release Caveat'
+ else
+ systemCountryCode = systemCountryCode[0];
+
+
+
+ # Obtain classification, add to banner. If none, default: 'UNCLASSIFIED'
+ classification = _.chain perms
+ .filter (perm) -> return perm.type is 'classification'
+ # there should only be a single classification label
+ .first()
+ # if no classification then default to unclassified
+ .value() || _id : 'U', label : 'UNCLASSIFIED'
+
+
+ # get all sci and sap labels, sort separately by trigraph
+ # join trigraphs separated by ' / ''
+ sciLabels = _.chain perms
+ .filter (perm) -> return perm.type is 'SCI'
+ .pluck 'trigraph'
+ .sort()
+ .value()
+ sapLabels = _.chain perms
+ .filter (perm) -> return perm.type is 'SAP'
+ .pluck 'trigraph'
+ .sort()
+ .value()
+ sciSapLabels = _.flatten [sciLabels, sapLabels]
+ .join ' / '
+
+
+ # get all rel-to countries, add to banner with ', ' separator
+ # if none specified (or only 'USA'), default to 'NOFORN'
+ reltoLabels = _.chain perms
+ .filter (perm) -> return perm.type is 'Release Caveat'
+ # exclude system country code because we prepend later as first country
+ .reject (perm) -> return perm._id is systemCountryCode._id
+ .pluck 'trigraph'
+ .sort()
+ .value()
+ # if still contains entries, hard-code system country code at front else 'NOFORN'
+ reltoLabels.splice(0, 0, ( if reltoLabels.length > 0 then 'REL TO ' + systemCountryCode.trigraph else 'NOFORN'))
+ reltoLabels = reltoLabels.join ', '
+
+
+ # stitch everything together
+ banner.classificationId = classification._id
+ banner.text = _.compact [classification.label.toUpperCase(), sciSapLabels, reltoLabels]
+ .join ' // '
+
+ return banner
\ No newline at end of file
diff --git a/server/publications/room.coffee b/server/publications/room.coffee
index 99a9c1434c00..8d29cb81da81 100644
--- a/server/publications/room.coffee
+++ b/server/publications/room.coffee
@@ -19,3 +19,4 @@ Meteor.publish 'room', (rid) ->
cl: 1
u: 1
usernames: 1
+ accessPermissions: 1