diff --git a/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6 b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6
new file mode 100644
index 000000000..c90efb35f
--- /dev/null
+++ b/app/assets/javascripts/discourse/components/tags-admin-dropdown.js.es6
@@ -0,0 +1,29 @@
+import { iconHTML } from 'discourse/helpers/fa-icon';
+import DropdownButton from 'discourse/components/dropdown-button';
+import computed from "ember-addons/ember-computed-decorators";
+
+export default DropdownButton.extend({
+  buttonExtraClasses: 'no-text',
+  title: '',
+  text: iconHTML('bars') + ' ' + iconHTML('caret-down'),
+  classNames: ['tags-admin-menu'],
+
+  @computed()
+  dropDownContent() {
+    const items = [
+      { id: 'manageGroups',
+        title: I18n.t('tagging.manage_groups'),
+        description: I18n.t('tagging.manage_groups_description'),
+        styleClasses: 'fa fa-wrench' }
+    ];
+    return items;
+  },
+
+  actionNames: {
+    manageGroups: 'showTagGroups'
+  },
+
+  clicked(id) {
+    this.sendAction('actionNames.' + id);
+  }
+});
diff --git a/app/assets/javascripts/discourse/controllers/tag-groups-show.js.es6 b/app/assets/javascripts/discourse/controllers/tag-groups-show.js.es6
new file mode 100644
index 000000000..1def5910f
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/tag-groups-show.js.es6
@@ -0,0 +1,7 @@
+export default Ember.Controller.extend({
+  actions: {
+    save() {
+      this.get('model').save();
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/controllers/tag-groups.js.es6 b/app/assets/javascripts/discourse/controllers/tag-groups.js.es6
new file mode 100644
index 000000000..4e52a8ce4
--- /dev/null
+++ b/app/assets/javascripts/discourse/controllers/tag-groups.js.es6
@@ -0,0 +1,18 @@
+export default Ember.ArrayController.extend({
+  actions: {
+    selectTagGroup: function(tagGroup) {
+      if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
+      this.set('selectedItem', tagGroup);
+      tagGroup.set('selected', true);
+      tagGroup.set('savingStatus', null);
+      this.transitionToRoute('tagGroups.show', tagGroup);
+    },
+
+    newTagGroup: function() {
+      const newTagGroup = this.store.createRecord('tag-group');
+      newTagGroup.set('name', I18n.t('tagging.groups.new_name'));
+      this.pushObject(newTagGroup);
+      this.send('selectTagGroup', newTagGroup);
+    }
+  }
+});
diff --git a/app/assets/javascripts/discourse/models/tag-group.js.es6 b/app/assets/javascripts/discourse/models/tag-group.js.es6
new file mode 100644
index 000000000..2bebcc333
--- /dev/null
+++ b/app/assets/javascripts/discourse/models/tag-group.js.es6
@@ -0,0 +1,34 @@
+import RestModel from 'discourse/models/rest';
+import computed from 'ember-addons/ember-computed-decorators';
+
+const TagGroup = RestModel.extend({
+  @computed('name', 'tag_names')
+  disableSave() {
+    return Ember.isEmpty(this.get('name')) || Ember.isEmpty(this.get('tag_names')) || this.get('saving');
+  },
+
+  save: function() {
+    var url = "/tag_groups",
+        self = this;
+    if (this.get('id')) {
+      url = "/tag_groups/" + this.get('id');
+    }
+
+    this.set('savingStatus', I18n.t('saving'));
+    this.set('saving', true);
+
+    return Discourse.ajax(url, {
+      data: {
+        name: this.get('name'),
+        tag_names: this.get('tag_names')
+      },
+      type: this.get('id') ? 'PUT' : 'POST'
+    }).then(function(result) {
+      if(result.id) { self.set('id', result.id); }
+      self.set('savingStatus', I18n.t('saved'));
+      self.set('saving', false);
+    });
+  }
+});
+
+export default TagGroup;
diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
index 2b06baa10..e7c2a9865 100644
--- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6
+++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6
@@ -131,4 +131,8 @@ export default function() {
       this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter});
     });
   });
+
+  this.resource('tagGroups', {path: '/tag_groups'}, function() {
+    this.route('show', {path: '/:id'});
+  });
 }
diff --git a/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6 b/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6
new file mode 100644
index 000000000..0d67542b2
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/tag-groups-show.js.es6
@@ -0,0 +1,5 @@
+export default Discourse.Route.extend({
+  model(params) {
+    return this.store.find('tagGroup', params.id);
+  }
+});
diff --git a/app/assets/javascripts/discourse/routes/tag-groups.js.es6 b/app/assets/javascripts/discourse/routes/tag-groups.js.es6
new file mode 100644
index 000000000..6d6476964
--- /dev/null
+++ b/app/assets/javascripts/discourse/routes/tag-groups.js.es6
@@ -0,0 +1,9 @@
+export default Discourse.Route.extend({
+  model() {
+    return this.store.findAll('tagGroup');
+  },
+
+  titleToken() {
+    return I18n.t("tagging.groups.title");
+  },
+});
diff --git a/app/assets/javascripts/discourse/routes/tags-index.js.es6 b/app/assets/javascripts/discourse/routes/tags-index.js.es6
index b6514ef27..dcbe6f192 100644
--- a/app/assets/javascripts/discourse/routes/tags-index.js.es6
+++ b/app/assets/javascripts/discourse/routes/tags-index.js.es6
@@ -18,6 +18,11 @@ export default Discourse.Route.extend({
     didTransition() {
       this.controllerFor("application").set("showFooter", true);
       return true;
+    },
+
+    showTagGroups() {
+      this.transitionTo('tagGroups');
+      return true;
     }
   }
 });
diff --git a/app/assets/javascripts/discourse/templates/tag-groups-index.hbs b/app/assets/javascripts/discourse/templates/tag-groups-index.hbs
new file mode 100644
index 000000000..0c933221d
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/tag-groups-index.hbs
@@ -0,0 +1,3 @@
+<div class="tag-group-content">
+  <p class="about">{{i18n 'tagging.groups.about'}}</p>
+</div>
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/templates/tag-groups-show.hbs b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs
new file mode 100644
index 000000000..e2555d40e
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/tag-groups-show.hbs
@@ -0,0 +1,12 @@
+<div class="tag-group-content">
+  <h1>{{text-field value=model.name}}</h1>
+  <br/>
+  <div class="group-tags-list">
+    <label>{{i18n 'tagging.groups.tags_label'}}</label>
+    <br/>
+    {{tag-chooser tags=model.tag_names everyTag="false" unlimitedTagCount="true"}}
+  </div>
+  <br/>
+  <button {{action "save"}} disabled={{model.disableSave}} class='btn'>{{i18n 'tagging.groups.save'}}</button>
+  <span class="saving {{unless model.savingStatus 'hidden'}}">{{model.savingStatus}}</span>
+</div>
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/templates/tag-groups.hbs b/app/assets/javascripts/discourse/templates/tag-groups.hbs
new file mode 100644
index 000000000..139874e75
--- /dev/null
+++ b/app/assets/javascripts/discourse/templates/tag-groups.hbs
@@ -0,0 +1,16 @@
+<div class="container tag-groups-container">
+  <h2>{{i18n "tagging.groups.title"}}</h2>
+
+  <div class='content-list'>
+    <ul>
+      {{#each model as |tagGroup|}}
+        <li><a {{action "selectTagGroup" tagGroup}} class="{{if tagGroup.selected 'active'}}">{{tagGroup.name}}</a></li>
+      {{/each}}
+    </ul>
+    <button {{action "newTagGroup"}} class='btn'><i class="fa fa-plus"></i>{{i18n 'tagging.groups.new'}}</button>
+  </div>
+
+  {{outlet}}
+
+  <div class="clearfix" />
+</div>
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/templates/tags/index.hbs b/app/assets/javascripts/discourse/templates/tags/index.hbs
index 239b2b6c1..6a31bfdf4 100644
--- a/app/assets/javascripts/discourse/templates/tags/index.hbs
+++ b/app/assets/javascripts/discourse/templates/tags/index.hbs
@@ -2,14 +2,20 @@
   {{discourse-banner user=currentUser banner=site.banner}}
 </div>
 
-<h2>{{i18n "tagging.tags"}}</h2>
+<div class="list-controls">
+  <div class="container">
+    {{tags-admin-dropdown}}
+    <h2>{{i18n "tagging.tags"}}</h2>
+  </div>
+</div>
 
 <div class='tag-sort-options'>
   {{i18n "tagging.sort_by"}}
   <a {{action "sortByCount"}}>{{i18n "tagging.sort_by_count"}}</a>
   <a {{action "sortById"}}>{{i18n "tagging.sort_by_name"}}</a>
 </div>
-<div class="clearfix" />
+
+<hr/>
 
 {{#each model.extras.categories as |category|}}
   {{tag-list tags=category.tags sortProperties=sortProperties categoryId=category.id}}
diff --git a/app/assets/stylesheets/common/admin/admin_base.scss b/app/assets/stylesheets/common/admin/admin_base.scss
index e86daf1d3..41b62309f 100644
--- a/app/assets/stylesheets/common/admin/admin_base.scss
+++ b/app/assets/stylesheets/common/admin/admin_base.scss
@@ -1200,44 +1200,6 @@ table.api-keys {
   }
 }
 
-.content-list {
-
-  h3 {
-    color: dark-light-diff($primary, $secondary, 50%, -20%);
-    font-size: 1.071em;
-    padding-left: 5px;
-    margin-bottom: 10px;
-  }
-
-  ul {
-    list-style: none;
-    margin: 0;
-
-    li:first-of-type {
-      border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
-    }
-    li {
-      border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
-    }
-
-    li a {
-      display: block;
-      padding: 10px;
-      color: $primary;
-
-      &:hover {
-        background-color: dark-light-diff($primary, $secondary, 90%, -60%);
-        color: $primary;
-      }
-
-      &.active {
-        font-weight: bold;
-        color: $primary;
-      }
-    }
-  }
-}
-
 .content-editor {
   min-height: 500px;
 
diff --git a/app/assets/stylesheets/common/base/discourse.scss b/app/assets/stylesheets/common/base/discourse.scss
index 5fe4eedfd..0d68b07b9 100644
--- a/app/assets/stylesheets/common/base/discourse.scss
+++ b/app/assets/stylesheets/common/base/discourse.scss
@@ -216,6 +216,44 @@ body {
   }
 }
 
+.content-list {
+
+  h3 {
+    color: dark-light-diff($primary, $secondary, 50%, -20%);
+    font-size: 1.071em;
+    padding-left: 5px;
+    margin-bottom: 10px;
+  }
+
+  ul {
+    list-style: none;
+    margin: 0;
+
+    li:first-of-type {
+      border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
+    }
+    li {
+      border-bottom: 1px solid dark-light-diff($primary, $secondary, 90%, -60%);
+    }
+
+    li a {
+      display: block;
+      padding: 10px;
+      color: $primary;
+
+      &:hover {
+        background-color: dark-light-diff($primary, $secondary, 90%, -60%);
+        color: $primary;
+      }
+
+      &.active {
+        font-weight: bold;
+        color: $primary;
+      }
+    }
+  }
+}
+
 // don't wrap relative dates, we want
 //
 // Jul 26, '15
diff --git a/app/assets/stylesheets/common/base/tagging.scss b/app/assets/stylesheets/common/base/tagging.scss
index 9fd6e3923..c1ed04ec9 100644
--- a/app/assets/stylesheets/common/base/tagging.scss
+++ b/app/assets/stylesheets/common/base/tagging.scss
@@ -201,3 +201,35 @@ header .discourse-tag {color: $tag-color !important; }
     color: $tag-color;
   }
 }
+
+.tags-admin-menu {
+  margin-top: 20px;
+  ul {
+    width: 320px;
+  }
+}
+
+.tag-groups-container {
+  margin-top: 20px;
+  .content-list {
+    width: 20%;
+    float: left;
+    margin: 20px 0;
+    ul {
+      margin-bottom: 10px;
+    }
+  }
+  .tag-group-content {
+    width: 75%;
+    float: right;
+  }
+  .group-tags-list .tag-chooser {
+    height: 150px !important;
+    .select2-choices {
+      height: 150px !important; // to fight with select2.scss's important
+    }
+  }
+  .saving {
+    margin-left: 10px;
+  }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/desktop/topic-list.scss b/app/assets/stylesheets/desktop/topic-list.scss
index 74c1ab80e..63d3e9679 100644
--- a/app/assets/stylesheets/desktop/topic-list.scss
+++ b/app/assets/stylesheets/desktop/topic-list.scss
@@ -272,11 +272,13 @@ button.dismiss-read {
   margin-left: 10px;
 }
 
-.category-notification-menu .dropdown-menu {
-  right: 0;
-  top: 30px;
-  bottom: auto;
-  left: auto;
+.category-notification-menu, .tags-admin-menu {
+  .dropdown-menu {
+    right: 0;
+    top: 30px;
+    bottom: auto;
+    left: auto;
+  }
 }
 
 .category-heading {
diff --git a/app/assets/stylesheets/mobile/topic-list.scss b/app/assets/stylesheets/mobile/topic-list.scss
index 01f656b6e..5ca8cb7b5 100644
--- a/app/assets/stylesheets/mobile/topic-list.scss
+++ b/app/assets/stylesheets/mobile/topic-list.scss
@@ -379,7 +379,7 @@ ol.category-breadcrumb {
   .btn-default.pull-right { margin-right: 10px; }
 }
 
-.category-notification-menu {
+.category-notification-menu, .tags-admin-menu {
   display: none;
 }
 
diff --git a/app/controllers/tag_groups_controller.rb b/app/controllers/tag_groups_controller.rb
new file mode 100644
index 000000000..9ba20c9da
--- /dev/null
+++ b/app/controllers/tag_groups_controller.rb
@@ -0,0 +1,62 @@
+class TagGroupsController < ApplicationController
+  skip_before_filter :check_xhr, only: [:index, :show]
+  before_filter :ensure_logged_in, except: [:index, :show]
+  before_filter :fetch_tag_group, only: [:show, :update, :destroy]
+
+  def index
+    tag_groups = TagGroup.order('name ASC').preload(:tags).all
+    serializer = ActiveModel::ArraySerializer.new(tag_groups, each_serializer: TagGroupSerializer, root: 'tag_groups')
+    respond_to do |format|
+      format.html do
+        store_preloaded "tagGroups", MultiJson.dump(serializer)
+        render "default/empty"
+      end
+      format.json { render_json_dump(serializer) }
+    end
+  end
+
+  def show
+    serializer = TagGroupSerializer.new(@tag_group)
+    respond_to do |format|
+      format.html do
+        store_preloaded "tagGroup", MultiJson.dump(serializer)
+        render "default/empty"
+      end
+      format.json { render_json_dump(serializer) }
+    end
+  end
+
+  def create
+    guardian.ensure_can_admin_tag_groups!
+    @tag_group = TagGroup.new(tag_groups_params)
+    if @tag_group.save
+      render_serialized(@tag_group, TagGroupSerializer)
+    else
+      return render_json_error(@tag_group)
+    end
+  end
+
+  def update
+    guardian.ensure_can_admin_tag_groups!
+    json_result(@tag_group, serializer: TagGroupSerializer) do |tag_group|
+      @tag_group.update(tag_groups_params)
+    end
+  end
+
+  def destroy
+    guardian.ensure_can_admin_tag_groups!
+    @tag_group.destroy
+    render json: success_json
+  end
+
+  private
+
+    def fetch_tag_group
+      @tag_group = TagGroup.find(params[:id])
+    end
+
+    def tag_groups_params
+      params[:tag_names] ||= []
+      params.permit(:id, :name, :tag_names => [])
+    end
+end
diff --git a/app/models/category.rb b/app/models/category.rb
index a6f516ad3..97341b9a4 100644
--- a/app/models/category.rb
+++ b/app/models/category.rb
@@ -316,16 +316,7 @@ SQL
   end
 
   def allowed_tags=(tag_names_arg)
-    tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user)) || []
-    if self.tags.pluck(:name).sort != tag_names.sort
-      self.tags = Tag.where(name: tag_names).all
-      if self.tags.size < tag_names.size
-        new_tag_names = tag_names - self.tags.map(&:name)
-        new_tag_names.each do |name|
-          self.tags << Tag.create(name: name)
-        end
-      end
-    end
+    DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg)
   end
 
   def downcase_email
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 12d7adf99..61cacb74d 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -9,6 +9,9 @@ class Tag < ActiveRecord::Base
   has_many :category_tags, dependent: :destroy
   has_many :categories, through: :category_tags
 
+  has_many :tag_group_memberships
+  has_many :tag_groups, through: :tag_group_memberships
+
   def self.tags_by_count_query(opts={})
     q = TopicTag.joins(:tag, :topic).group("topic_tags.tag_id, tags.name").order('count_all DESC')
     q = q.limit(opts[:limit]) if opts[:limit]
diff --git a/app/models/tag_group.rb b/app/models/tag_group.rb
new file mode 100644
index 000000000..9bd7f11bb
--- /dev/null
+++ b/app/models/tag_group.rb
@@ -0,0 +1,8 @@
+class TagGroup < ActiveRecord::Base
+  has_many :tag_group_memberships, dependent: :destroy
+  has_many :tags, through: :tag_group_memberships
+
+  def tag_names=(tag_names_arg)
+    DiscourseTagging.add_or_create_tags_by_name(self, tag_names_arg)
+  end
+end
diff --git a/app/models/tag_group_membership.rb b/app/models/tag_group_membership.rb
new file mode 100644
index 000000000..0fee1fdcf
--- /dev/null
+++ b/app/models/tag_group_membership.rb
@@ -0,0 +1,4 @@
+class TagGroupMembership < ActiveRecord::Base
+  belongs_to :tag
+  belongs_to :tag_group, counter_cache: "tag_count"
+end
diff --git a/app/serializers/tag_group_serializer.rb b/app/serializers/tag_group_serializer.rb
new file mode 100644
index 000000000..0aa0f412d
--- /dev/null
+++ b/app/serializers/tag_group_serializer.rb
@@ -0,0 +1,7 @@
+class TagGroupSerializer < ApplicationSerializer
+  attributes :id, :name, :tag_names
+
+  def tag_names
+    object.tags.pluck(:name).sort
+  end
+end
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 60bcbe02d..b9e43f883 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2986,6 +2986,8 @@ en:
       sort_by: "Sort by:"
       sort_by_count: "count"
       sort_by_name: "name"
+      manage_groups: "Manage Tag Groups"
+      manage_groups_description: "Define groups to organize tags"
 
       filters:
         without_category: "%{filter} %{tag} topics"
@@ -3005,6 +3007,14 @@ en:
           title: "Muted"
           description: "You will not be notified of anything about new topics in this tag, and they will not appear on your unread tab."
 
+      groups:
+        title: "Tag Groups"
+        about: "Add tags to groups to manage them more easily."
+        new: "New Group"
+        tags_label: "Tags in this group:"
+        new_name: "New Tag Group"
+        save: "Save"
+
       topics:
         none:
           unread: "You have no unread topics."
diff --git a/config/routes.rb b/config/routes.rb
index 626d2a0a8..6baaabd8d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -636,6 +636,7 @@ Discourse::Application.routes.draw do
       end
     end
   end
+  resources :tag_groups, except: [:new, :edit]
 
   Discourse.filters.each do |filter|
     root to: "list##{filter}", constraints: HomePageConstraint.new("#{filter}"), :as => "list_#{filter}"
diff --git a/db/migrate/20160602164008_create_tag_groups.rb b/db/migrate/20160602164008_create_tag_groups.rb
new file mode 100644
index 000000000..e851ff39b
--- /dev/null
+++ b/db/migrate/20160602164008_create_tag_groups.rb
@@ -0,0 +1,17 @@
+class CreateTagGroups < ActiveRecord::Migration
+  def change
+    create_table :tag_groups do |t|
+      t.string :name,       null: false
+      t.integer :tag_count, null: false, default: 0
+      t.timestamps
+    end
+
+    create_table :tag_group_memberships do |t|
+      t.references :tag,       null: false
+      t.references :tag_group, null: false
+      t.timestamps
+    end
+
+    add_index :tag_group_memberships, [:tag_group_id, :tag_id], unique: true
+  end
+end
diff --git a/lib/discourse_tagging.rb b/lib/discourse_tagging.rb
index ca6b9bdc5..48ce1c00d 100644
--- a/lib/discourse_tagging.rb
+++ b/lib/discourse_tagging.rb
@@ -120,10 +120,25 @@ module DiscourseTagging
     return tag_names[0...SiteSetting.max_tags_per_topic]
   end
 
+  def self.add_or_create_tags_by_name(taggable, tag_names_arg)
+    tag_names = DiscourseTagging.tags_for_saving(tag_names_arg, Guardian.new(Discourse.system_user)) || []
+    if taggable.tags.pluck(:name).sort != tag_names.sort
+      taggable.tags = Tag.where(name: tag_names).all
+      if taggable.tags.size < tag_names.size
+        new_tag_names = tag_names - taggable.tags.map(&:name)
+        new_tag_names.each do |name|
+          taggable.tags << Tag.create(name: name)
+        end
+      end
+    end
+  end
+
+  # TODO: this is unused?
   def self.notification_key(tag_id)
     "tags_notification:#{tag_id}"
   end
 
+  # TODO: this is unused?
   def self.muted_tags(user)
     return [] unless user
     UserCustomField.where(user_id: user.id, value: TopicUser.notification_levels[:muted]).pluck(:name).map { |x| x[0,17] == "tags_notification" ? x[18..-1] : nil}.compact
diff --git a/lib/guardian.rb b/lib/guardian.rb
index 2e68f2463..717a90adc 100644
--- a/lib/guardian.rb
+++ b/lib/guardian.rb
@@ -5,6 +5,7 @@ require_dependency 'guardian/topic_guardian'
 require_dependency 'guardian/user_guardian'
 require_dependency 'guardian/post_revision_guardian'
 require_dependency 'guardian/group_guardian'
+require_dependency 'guardian/tag_guardian'
 
 # The guardian is responsible for confirming access to various site resources and operations
 class Guardian
@@ -15,6 +16,7 @@ class Guardian
   include UserGuardian
   include PostRevisionGuardian
   include GroupGuardian
+  include TagGuardian
 
   class AnonymousUser
     def blank?; true; end
@@ -277,17 +279,6 @@ class Guardian
     UserExport.where(user_id: @user.id, created_at: (Time.zone.now.beginning_of_day..Time.zone.now.end_of_day)).count == 0
   end
 
-  def can_create_tag?
-    user && user.has_trust_level?(SiteSetting.min_trust_to_create_tag.to_i)
-  end
-
-  def can_tag_topics?
-    user && user.has_trust_level?(SiteSetting.min_trust_level_to_tag_topics.to_i)
-  end
-
-  def can_admin_tags?
-    is_staff?
-  end
 
   private
 
diff --git a/lib/guardian/tag_guardian.rb b/lib/guardian/tag_guardian.rb
new file mode 100644
index 000000000..b9315078d
--- /dev/null
+++ b/lib/guardian/tag_guardian.rb
@@ -0,0 +1,18 @@
+#mixin for all guardian methods dealing with tagging permisions
+module TagGuardian
+  def can_create_tag?
+    user && SiteSetting.tagging_enabled && user.has_trust_level?(SiteSetting.min_trust_to_create_tag.to_i)
+  end
+
+  def can_tag_topics?
+    user && user.has_trust_level?(SiteSetting.min_trust_level_to_tag_topics.to_i)
+  end
+
+  def can_admin_tags?
+    is_staff? && SiteSetting.tagging_enabled
+  end
+
+  def can_admin_tag_groups?
+    is_staff? && SiteSetting.tagging_enabled
+  end
+end
diff --git a/spec/components/guardian_spec.rb b/spec/components/guardian_spec.rb
index 1f130d4da..3346b97aa 100644
--- a/spec/components/guardian_spec.rb
+++ b/spec/components/guardian_spec.rb
@@ -2208,4 +2208,62 @@ describe Guardian do
       end
     end
   end
+
+  describe "Tags" do
+    context "tagging disabled" do
+      before do
+        SiteSetting.tagging_enabled = false
+      end
+
+      it "can_create_tag returns false" do
+        expect(Guardian.new(admin).can_create_tag?).to be_falsey
+      end
+
+      it "can_admin_tags returns false" do
+        expect(Guardian.new(admin).can_admin_tags?).to be_falsey
+      end
+
+      it "can_admin_tag_groups returns false" do
+        expect(Guardian.new(admin).can_admin_tag_groups?).to be_falsey
+      end
+    end
+
+    context "tagging is enabled" do
+      before do
+        SiteSetting.tagging_enabled = true
+        SiteSetting.min_trust_to_create_tag = 3
+        SiteSetting.min_trust_level_to_tag_topics = 1
+      end
+
+      describe "can_create_tag" do
+        it "returns false if trust level is too low" do
+          expect(Guardian.new(trust_level_2).can_create_tag?).to be_falsey
+        end
+
+        it "returns true if trust level is high enough" do
+          expect(Guardian.new(trust_level_3).can_create_tag?).to be_truthy
+        end
+
+        it "returns true for staff" do
+          expect(Guardian.new(admin).can_create_tag?).to be_truthy
+          expect(Guardian.new(moderator).can_create_tag?).to be_truthy
+        end
+      end
+
+      describe "can_tag_topics" do
+        it "returns false if trust level is too low" do
+          expect(Guardian.new(Fabricate(:user, trust_level: 0)).can_tag_topics?).to be_falsey
+        end
+
+        it "returns true if trust level is high enough" do
+          expect(Guardian.new(Fabricate(:user, trust_level: 1)).can_tag_topics?).to be_truthy
+        end
+
+        it "returns true for staff" do
+          expect(Guardian.new(admin).can_tag_topics?).to be_truthy
+          expect(Guardian.new(moderator).can_tag_topics?).to be_truthy
+        end
+      end
+    end
+  end
 end