Skip to content
Permalink
Browse files

FEATURE: Overhaul of admin API key system (#8284)

- Allow revoking keys without deleting them
- Auto-revoke keys after a period of no use (default 6 months)
- Allow multiple keys per user
- Allow attaching a description to each key, for easier auditing
- Log changes to keys in the staff action log
- Move all key management to one place, and improve the UI
  • Loading branch information...
davidtaylorhq committed Nov 5, 2019
1 parent fa2c06d commit 52c5cf33f87990a5828a34f68ff9df833cdc4a15
Showing with 857 additions and 389 deletions.
  1. +11 −0 app/assets/javascripts/admin/adapters/api-key.js.es6
  2. +13 −0 app/assets/javascripts/admin/controllers/admin-api-keys-index.js.es6
  3. +39 −0 app/assets/javascripts/admin/controllers/admin-api-keys-new.js.es6
  4. +54 −0 app/assets/javascripts/admin/controllers/admin-api-keys-show.js.es6
  5. +0 −42 app/assets/javascripts/admin/controllers/admin-api-keys.js.es6
  6. +0 −30 app/assets/javascripts/admin/controllers/admin-user-index.js.es6
  7. +0 −11 app/assets/javascripts/admin/models/admin-user.js.es6
  8. +41 −33 app/assets/javascripts/admin/models/api-key.js.es6
  9. +7 −0 app/assets/javascripts/admin/routes/admin-api-keys-index.js.es6
  10. +7 −0 app/assets/javascripts/admin/routes/admin-api-keys-new.es6
  11. +5 −0 app/assets/javascripts/admin/routes/admin-api-keys-show.js.es6
  12. +8 −3 app/assets/javascripts/admin/routes/admin-api-keys.js.es6
  13. +8 −1 app/assets/javascripts/admin/routes/admin-route-map.js.es6
  14. +31 −21 app/assets/javascripts/admin/templates/{api-keys.hbs → api-keys-index.hbs}
  15. +27 −0 app/assets/javascripts/admin/templates/api-keys-new.hbs
  16. +80 −0 app/assets/javascripts/admin/templates/api-keys-show.hbs
  17. +8 −27 app/assets/javascripts/admin/templates/user-index.hbs
  18. +55 −4 app/assets/stylesheets/common/admin/api.scss
  19. +78 −8 app/controllers/admin/api_controller.rb
  20. +0 −13 app/controllers/admin/users_controller.rb
  21. +14 −0 app/jobs/scheduled/clean_up_unused_api_keys.rb
  22. +32 −11 app/models/api_key.rb
  23. +1 −14 app/models/user.rb
  24. +8 −2 app/models/user_history.rb
  25. +5 −2 app/serializers/admin_detailed_user_serializer.rb
  26. +4 −1 app/serializers/api_key_serializer.rb
  27. +33 −0 app/services/staff_action_logger.rb
  28. +1 −1 app/services/user_anonymizer.rb
  29. +21 −6 config/locales/client.en.yml
  30. +7 −0 config/locales/server.en.yml
  31. +6 −5 config/routes.rb
  32. +5 −0 config/site_settings.yml
  33. +17 −0 db/migrate/20191101113230_add_revoked_at_to_api_key.rb
  34. +1 −1 lib/auth/default_current_user_provider.rb
  35. +18 −0 spec/components/auth/default_current_user_provider_spec.rb
  36. +52 −5 spec/models/api_key_spec.rb
  37. +0 −50 spec/models/user_spec.rb
  38. +134 −32 spec/requests/admin/api_controller_spec.rb
  39. +16 −24 spec/requests/admin/users_controller_spec.rb
  40. +1 −1 spec/requests/embed_controller_spec.rb
  41. +5 −5 spec/requests/posts_controller_spec.rb
  42. +1 −1 spec/requests/topics_controller_spec.rb
  43. +2 −2 spec/requests/user_badges_controller_spec.rb
  44. +1 −1 spec/services/user_anonymizer_spec.rb
  45. +0 −30 test/javascripts/admin/models/admin-user-test.js.es6
  46. +0 −2 test/javascripts/helpers/create-pretender.js.es6
@@ -0,0 +1,11 @@
import RESTAdapter from "discourse/adapters/rest";

export default RESTAdapter.extend({
basePath() {
return "/admin/api/";
},

apiNameFor() {
return "key";
}
});
@@ -0,0 +1,13 @@
import { popupAjaxError } from "discourse/lib/ajax-error";

export default Ember.Controller.extend({
actions: {
revokeKey(key) {
key.revoke().catch(popupAjaxError);
},

undoRevokeKey(key) {
key.undoRevoke().catch(popupAjaxError);
}
}
});
@@ -0,0 +1,39 @@
import { default as computed } from "ember-addons/ember-computed-decorators";
import { popupAjaxError } from "discourse/lib/ajax-error";

export default Ember.Controller.extend({
userModes: [
{ id: "all", name: I18n.t("admin.api.all_users") },
{ id: "single", name: I18n.t("admin.api.single_user") }
],

@computed("userMode")
showUserSelector(mode) {
return mode === "single";
},

@computed("model.description", "model.username", "userMode")
saveDisabled(description, username, userMode) {
if (Ember.isBlank(description)) return true;
if (userMode === "single" && Ember.isBlank(username)) return true;
return false;
},

actions: {
changeUserMode(value) {
if (value === "all") {
this.model.set("username", null);
}
this.set("userMode", value);
},

save() {
this.model
.save()
.then(() => {
this.transitionToRoute("adminApiKeys.show", this.model.id);
})
.catch(popupAjaxError);
}
}
});
@@ -0,0 +1,54 @@
import { bufferedProperty } from "discourse/mixins/buffered-content";
import { popupAjaxError } from "discourse/lib/ajax-error";
import { empty } from "@ember/object/computed";

export default Ember.Controller.extend(bufferedProperty("model"), {
isNew: empty("model.id"),

actions: {
saveDescription() {
const buffered = this.buffered;
const attrs = buffered.getProperties("description");

this.model
.save(attrs)
.then(() => {
this.set("editingDescription", false);
this.rollbackBuffer();
})
.catch(popupAjaxError);
},

cancel() {
const id = this.get("userField.id");
if (Ember.isEmpty(id)) {
this.destroyAction(this.userField);
} else {
this.rollbackBuffer();
this.set("editing", false);
}
},

editDescription() {
this.toggleProperty("editingDescription");
if (!this.editingDescription) {
this.rollbackBuffer();
}
},

revokeKey(key) {
key.revoke().catch(popupAjaxError);
},

deleteKey(key) {
key
.destroyRecord()
.then(() => this.transitionToRoute("adminApiKeys.index"))
.catch(popupAjaxError);
},

undoRevokeKey(key) {
key.undoRevoke().catch(popupAjaxError);
}
}
});
@@ -1,42 +0,0 @@
import ApiKey from "admin/models/api-key";
import { default as computed } from "ember-addons/ember-computed-decorators";
import Controller from "@ember/controller";

export default Controller.extend({
@computed("model.[]")
hasMasterKey(model) {
return !!model.findBy("user", null);
},

actions: {
generateMasterKey() {
ApiKey.generateMasterKey().then(key => this.model.pushObject(key));
},

regenerateKey(key) {
bootbox.confirm(
I18n.t("admin.api.confirm_regen"),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
key.regenerate();
}
}
);
},

revokeKey(key) {
bootbox.confirm(
I18n.t("admin.api.confirm_revoke"),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
key.revoke().then(() => this.model.removeObject(key));
}
}
);
}
}
});
@@ -258,10 +258,6 @@ export default Controller.extend(CanCheckEmails, {
.finally(() => this.toggleProperty("editingTitle"));
},

generateApiKey() {
this.model.generateApiKey();
},

saveCustomGroups() {
const currentIds = this.customGroupIds;
const bufferedIds = this.customGroupIdsBuffer;
@@ -294,32 +290,6 @@ export default Controller.extend(CanCheckEmails, {

resetPrimaryGroup() {
this.set("model.primary_group_id", this.originalPrimaryGroupId);
},

regenerateApiKey() {
bootbox.confirm(
I18n.t("admin.api.confirm_regen"),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
this.model.generateApiKey();
}
}
);
},

revokeApiKey() {
bootbox.confirm(
I18n.t("admin.api.confirm_revoke"),
I18n.t("no_value"),
I18n.t("yes_value"),
result => {
if (result) {
this.model.revokeApiKey();
}
}
);
}
}
});
@@ -4,7 +4,6 @@ import { ajax } from "discourse/lib/ajax";
import computed from "ember-addons/ember-computed-decorators";
import { propertyNotEqual } from "discourse/lib/computed";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ApiKey from "admin/models/api-key";
import Group from "discourse/models/group";
import { userPath } from "discourse/lib/url";

@@ -57,16 +56,6 @@ const AdminUser = Discourse.User.extend({
);
},

generateApiKey() {
return ajax(`/admin/users/${this.id}/generate_api_key`, {
type: "POST"
}).then(result => {
const apiKey = ApiKey.create(result.api_key);
this.set("api_key", apiKey);
return apiKey;
});
},

groupAdded(added) {
return ajax(`/admin/users/${this.id}/groups`, {
type: "POST",
@@ -1,47 +1,55 @@
import AdminUser from "admin/models/admin-user";
import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
import computed from "ember-addons/ember-computed-decorators";

const KEY_ENDPOINT = "/admin/api/key";
const KEYS_ENDPOINT = "/admin/api/keys";

const ApiKey = Discourse.Model.extend({
regenerate() {
return ajax(KEY_ENDPOINT, {
type: "PUT",
data: { id: this.id }
}).then(result => {
this.set("key", result.api_key.key);
return this;
});
const ApiKey = RestModel.extend({
user: Ember.computed("_user", {
get() {
return this._user;
},
set(key, value) {
if (value && !(value instanceof AdminUser)) {
this.set("_user", AdminUser.create(value));
} else {
this.set("_user", value);
}
return this._user;
}
}),

@computed("key")
shortKey(key) {
return `${key.substring(0, 4)}...`;
},

@computed("description")
shortDescription(description) {
if (!description || description.length < 40) return description;
return `${description.substring(0, 40)}...`;
},

revoke() {
return ajax(KEY_ENDPOINT, {
type: "DELETE",
data: { id: this.id }
});
}
});
return ajax(`${this.basePath}/revoke`, {
type: "POST"
}).then(result => this.setProperties(result.api_key));
},

ApiKey.reopenClass({
create() {
const result = this._super.apply(this, arguments);
if (result.user) {
result.user = AdminUser.create(result.user);
}
return result;
undoRevoke() {
return ajax(`${this.basePath}/undo-revoke`, {
type: "POST"
}).then(result => this.setProperties(result.api_key));
},

find() {
return ajax(KEYS_ENDPOINT).then(keys =>
keys.map(key => ApiKey.create(key))
);
createProperties() {
return this.getProperties("description", "username");
},

generateMasterKey() {
return ajax(KEY_ENDPOINT, { type: "POST" }).then(result =>
ApiKey.create(result.api_key)
);
@computed()
basePath() {
return this.store
.adapterFor("api-key")
.pathFor(this.store, "api-key", this.id);
}
});

@@ -0,0 +1,7 @@
import Route from "@ember/routing/route";

export default Route.extend({
model() {
return this.store.findAll("api-key");
}
});
@@ -0,0 +1,7 @@
import Route from "@ember/routing/route";

export default Route.extend({
model() {
return this.store.createRecord("api-key");
}
});
@@ -0,0 +1,5 @@
export default Ember.Route.extend({
model(params) {
return this.store.find("api-key", params.api_key_id);
}
});
@@ -1,8 +1,13 @@
import Route from "@ember/routing/route";
import ApiKey from "admin/models/api-key";

export default Route.extend({
model() {
return ApiKey.find();
actions: {
show(apiKey) {
this.transitionTo("adminApiKeys.show", apiKey.id);
},

new() {
this.transitionTo("adminApiKeys.new");
}
}
});
@@ -101,7 +101,14 @@ export default function() {
);

this.route("adminApi", { path: "/api", resetNamespace: true }, function() {
this.route("adminApiKeys", { path: "/keys", resetNamespace: true });
this.route(
"adminApiKeys",
{ path: "/keys", resetNamespace: true },
function() {
this.route("show", { path: "/:api_key_id" });
this.route("new", { path: "/new" });
}
);

this.route(
"adminWebHooks",

1 comment on commit 52c5cf3

@discoursebot

This comment has been minimized.

Copy link

discoursebot commented on 52c5cf3 Nov 8, 2019

This commit has been mentioned on Discourse Meta. There might be relevant details there:

https://meta.discourse.org/t/rake-api-key-get-broken/132948/1

Please sign in to comment.
You can’t perform that action at this time.