checkpoint 0.2.2 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (57) hide show
  1. checksums.yaml +7 -0
  2. data/.envrc +1 -0
  3. data/.gitignore +18 -9
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +30 -0
  6. data/.travis.yml +5 -0
  7. data/.yardopts +1 -0
  8. data/Gemfile +5 -1
  9. data/LICENSE.md +27 -0
  10. data/README.md +23 -0
  11. data/Rakefile +14 -0
  12. data/bin/console +18 -0
  13. data/bin/rake +21 -0
  14. data/bin/rspec +21 -0
  15. data/bin/sequel +21 -0
  16. data/bin/setup +8 -0
  17. data/bin/yard +21 -0
  18. data/bin/yardoc +21 -0
  19. data/checkpoint.gemspec +37 -19
  20. data/db/migrations/1_create_permits.rb +19 -0
  21. data/docs/Makefile +24 -0
  22. data/docs/_static/.gitkeep +0 -0
  23. data/docs/_templates/.gitkeep +0 -0
  24. data/docs/authentication.rst +18 -0
  25. data/docs/conf.py +46 -0
  26. data/docs/index.rst +28 -0
  27. data/docs/policies.rst +211 -0
  28. data/docs/requirements.txt +4 -0
  29. data/lib/checkpoint.rb +16 -2
  30. data/lib/checkpoint/agent.rb +93 -0
  31. data/lib/checkpoint/agent/resolver.rb +33 -0
  32. data/lib/checkpoint/agent/token.rb +52 -0
  33. data/lib/checkpoint/authority.rb +67 -0
  34. data/lib/checkpoint/credential.rb +82 -0
  35. data/lib/checkpoint/credential/permission.rb +27 -0
  36. data/lib/checkpoint/credential/resolver.rb +87 -0
  37. data/lib/checkpoint/credential/role.rb +26 -0
  38. data/lib/checkpoint/credential/token.rb +51 -0
  39. data/lib/checkpoint/db.rb +161 -0
  40. data/lib/checkpoint/db/permit.rb +24 -0
  41. data/lib/checkpoint/permission_mapper.rb +29 -0
  42. data/lib/checkpoint/permits.rb +133 -0
  43. data/lib/checkpoint/query.rb +42 -0
  44. data/lib/checkpoint/query/action_permitted.rb +40 -0
  45. data/lib/checkpoint/query/role_granted.rb +55 -0
  46. data/lib/checkpoint/railtie.rb +92 -71
  47. data/lib/checkpoint/resource.rb +138 -0
  48. data/lib/checkpoint/resource/all_of_any_type.rb +34 -0
  49. data/lib/checkpoint/resource/all_of_type.rb +50 -0
  50. data/lib/checkpoint/resource/any_entity.rb +25 -0
  51. data/lib/checkpoint/resource/any_entity_of_type.rb +29 -0
  52. data/lib/checkpoint/resource/resolver.rb +21 -0
  53. data/lib/checkpoint/resource/token.rb +65 -0
  54. data/lib/checkpoint/version.rb +3 -1
  55. data/lib/tasks/migrate.rake +75 -0
  56. metadata +260 -19
  57. data/Readme.markdown +0 -103
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'checkpoint/agent/resolver'
4
+ require 'checkpoint/credential/resolver'
5
+ require 'checkpoint/resource/resolver'
6
+ require 'checkpoint/permits'
7
+
8
+ module Checkpoint
9
+ # An Authority is the central point of contact for authorization questions in
10
+ # Checkpoint. It checks whether there are permits that would allow a given
11
+ # action to be taken.
12
+ class Authority
13
+ def initialize(
14
+ agent_resolver: Agent::Resolver.new,
15
+ credential_resolver: Credential::Resolver.new,
16
+ resource_resolver: Resource::Resolver.new,
17
+ permits: Permits.new)
18
+
19
+ @agent_resolver = agent_resolver
20
+ @credential_resolver = credential_resolver
21
+ @resource_resolver = resource_resolver
22
+ @permits = permits
23
+ end
24
+
25
+ def permits?(agent, credential, resource)
26
+ # Conceptually equivalent to:
27
+ # can?(agent, action, target)
28
+ # can?(current_user, 'edit', @listing)
29
+
30
+ # user => agent tokens
31
+ # action => credential tokens
32
+ # target => resource tokens
33
+
34
+ # Permit.where(agent: agents, credential: credentials, resource: resources)
35
+ # SELECT * FROM permits
36
+ # WHERE agent IN('user:gkostin', 'account-type:umich', 'affiliation:lib-staff')
37
+ # AND credential IN('permission:edit', 'role:editor')
38
+ # AND resource IN('listing:17', 'type:listing')
39
+
40
+ # agent_type, agent_id | cred_type, cred_id | resource_type, resource_id
41
+ # ------------------------------------------------------------------------
42
+ # 'user:gkostin' | 'permission:edit' | 'listing:17'
43
+ # 'account-type:umich' | 'role:editor' | 'type:listing'
44
+ # 'affiliation:lib-staff' | | 'listing:*'
45
+
46
+ # ^^^ ^^^^ ^^^^
47
+ # if current_user has at least one row in each of of these columns,
48
+ # they have been "granted permission"
49
+ permits.for(
50
+ agent_resolver.resolve(agent),
51
+ credential_resolver.resolve(credential),
52
+ resource_resolver.resolve(resource)
53
+ ).any?
54
+ end
55
+
56
+ # Dummy authority that rejects everything
57
+ class RejectAll
58
+ def permits?(*)
59
+ false
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :agent_resolver, :credential_resolver, :resource_resolver, :permits
66
+ end
67
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'checkpoint/credential/resolver'
4
+ require 'checkpoint/credential/role'
5
+ require 'checkpoint/credential/permission'
6
+ require 'checkpoint/credential/token'
7
+ require 'checkpoint/permission_mapper'
8
+
9
+ module Checkpoint
10
+ # A Credential is the permission to take a particular action, or any
11
+ # instrument that can represent multiple permissions, such as a role or
12
+ # license.
13
+ #
14
+ # Credentials are abstract; that is, they are not attached to a particular
15
+ # actor or resource to be acted upon. A credential can be granted to an
16
+ # {Agent}, optionally applying to a particular resource, by way of a Permit.
17
+ # In other words, a credential can be likened to a class, while a permit can
18
+ # be likened to an instance of that class, bound to a given agent and
19
+ # possibly bound to a {Resource}.
20
+ class Credential
21
+ attr_reader :type, :id
22
+ alias name id
23
+
24
+ # Create a new generic Credential. This should generally not be called,
25
+ # preferring to use a factory or instantiate a {Permission}, {Role}, or
26
+ # custom Credential class.
27
+ #
28
+ # This class assigns the type 'credential', while most often, applications
29
+ # will want a {Permission}.
30
+ #
31
+ # The term `name` is more intuitive for credentials than `id`, as is used
32
+ # with the {Agent} and {Resource} types. This is because most applications
33
+ # will use primitive strings or symbols as the programmatic objects for
34
+ # credentials, where as `id` is often associated with a database-assigned
35
+ # identifier that should not appear in the source code. The parameter is
36
+ # called `name` here to reflect that intuitive concept, but it is really
37
+ # an alias for the `id` property of this Credential.
38
+ #
39
+ # @param name [String|Symbol] the name of this credential
40
+ def initialize(name)
41
+ @id = name.to_s
42
+ @type = 'credential'
43
+ end
44
+
45
+ # Return the list of Credentials that would grant this one.
46
+ #
47
+ # This is an extension mechanism for application authors needing to
48
+ # implement hierarchical or virtual credentials and wanting to do so in
49
+ # an object-oriented way. The default implementation is to simply return
50
+ # the credential itself in an array but, for example, an a custom
51
+ # permission type could provide for aliasing by including itself and
52
+ # another instance for the synonym. Another example is modeling permissions
53
+ # granted by particular roles; this might be static, as defined in the
54
+ # source files, or dynamic, as impacted by configuration or runtime data.
55
+ #
56
+ # As an alternative, these rules could be implemented under a
57
+ # {PermissionMapper} in an application that prefers to model its credentials
58
+ # as strings or symbols, rather than more specialized objects.
59
+ #
60
+ # @see Checkpoint::PermissionMapper
61
+ # @return [Array<Credential>] the expanded list of credentials that would
62
+ # grant this one
63
+ def granted_by
64
+ [self]
65
+ end
66
+
67
+ # @return [Token] a token for this credential
68
+ def token
69
+ @token ||= Token.new(type, id)
70
+ end
71
+
72
+ # Compare two Credentials.
73
+ # @param other [Credential] the Credential to compare
74
+ # @return [Boolean] true if `other` is a Credential and its type and id
75
+ # are both eql? to {#type} and {#id}
76
+ def eql?(other)
77
+ type.eql?(other.type) && name.eql?(other.id)
78
+ end
79
+
80
+ alias == eql?
81
+ end
82
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkpoint
4
+ class Credential
5
+ # A Permission is simple extension to the base Credential, specifying its
6
+ # type as a permission and providing a conceptual object to be instantiated
7
+ # or passed.
8
+ #
9
+ # The most common use from outside Checkpoint will be by way of
10
+ # {Checkpoint::Query::ActionPermitted}, which will ask whether a given named
11
+ # action is permitted for a user. However, Permission could be extended or
12
+ # modified to implement aliasing or hierarchy, for example.
13
+ #
14
+ # More likely, though, is advising the resolution of Permissions through a
15
+ # {Checkpoint::PermissionMapper} or implementing a custom
16
+ # {Checkpoint::Credential::Resolver}. Subclassing or monkey-patching Permission
17
+ # should only be necessary if the application needs to extend the actual
18
+ # behavior of the Permission objects, rather than just which ones are resolved.
19
+ class Permission < Credential
20
+ TYPE = 'permission'
21
+
22
+ def type
23
+ TYPE
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'checkpoint/permission_mapper'
4
+
5
+ module Checkpoint
6
+ class Credential
7
+ # A Credential Resolver takes a concrete action name and resolves it into any
8
+ # {Credential}s that would permit the action.
9
+ #
10
+ # Checkpoint makes no particular demand on the credential model for an
11
+ # application, but offers a useful default implementation supporting
12
+ # permissions and roles. There are no default rules in Checkpoint as to which
13
+ # permissions or roles exist and, therefore, it has no default mapping of
14
+ # roles to permissions.
15
+ #
16
+ # This default resolver can be advised about an application model using roles
17
+ # and permissions customized by using one or both of two extension points:
18
+ #
19
+ # 1. Supplying a {PermissionMapper} gives a way to map action names to any
20
+ # "larger" permissions (e.g., "manage" being a shorthand for all CRUD
21
+ # operations on a Resource type) or roles that would grant a given
22
+ # permission. This affords a rather straightforward mapping of strings
23
+ # or symbols, short of building customized Credential types.
24
+ #
25
+ # 2. Implementing your own {Credential} types gives a way to model an
26
+ # application's credentials in an object-oriented way. If the resolver
27
+ # receives a {Credential} (rather than a string or symbol), it will call
28
+ # `#granted_by` on it to expand it. The Credential should be sure to
29
+ # include itself in the array it returns unless it is virtual and should
30
+ # never be considered as granted directly.
31
+ #
32
+ class Resolver
33
+ def initialize(permission_mapper: PermissionMapper.new)
34
+ @permission_mapper = permission_mapper
35
+ end
36
+
37
+ # Resolve an action into all {Credential}s that would permit it.
38
+ #
39
+ # When supplied a string or symbol, we call `permissions_for` and
40
+ # `roles_granting` on the {PermissionMapper} creating a {Permission} or
41
+ # {Role} for every result, returning them all in an array.
42
+ #
43
+ # When supplied a Credential, we call `#granted_by` on it and bypass the
44
+ # PermissionMapper. More precisely, we only check that the object responds to
45
+ # `#granted_by?`, but it would generally be a Credential subclass. The
46
+ # Credential should return an array, but we ensure the return type by
47
+ # wrapping and flattening.
48
+ #
49
+ # Note that the parameter name to `resolve` is `action`. This isn't a perfect
50
+ # name, but credentials are polymorphic such a way that there really is no
51
+ # better application-side term (cf. actor -> Agent, entity -> Resource). It
52
+ # would be something like `action_or_role`, `permission_or_role`, or a generic
53
+ # `credential`. Part of the naming intent here was to distinguish from the
54
+ # action and the ability to perform it. This inheritance relationship
55
+ # permissions and roles are both credential types is a distinguishing feature
56
+ # of Checkpoint, as opposed to models that treat permissions and roles as
57
+ # distinct concepts that must be granted in very different ways. A better
58
+ # name for this parameter may emerge over time, but it seems unlikely. The
59
+ # name `action` was selected because the most common and appropriate concrete
60
+ # thing to look for is a permission to take a named application action.
61
+ #
62
+ # @param action [String|Symbol|Credential] the action name or Credential
63
+ # to expand into any Credential that would grant it.
64
+ def resolve(action)
65
+ if action.respond_to?(:granted_by)
66
+ [action.granted_by].flatten
67
+ else
68
+ permissions_for(action) + roles_granting(action)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def permissions_for(action)
75
+ perms = permission_mapper.permissions_for(action)
76
+ perms.map { |perm| Credential::Permission.new(perm) }
77
+ end
78
+
79
+ def roles_granting(action)
80
+ roles = permission_mapper.roles_granting(action)
81
+ roles.map { |role| Credential::Role.new(role) }
82
+ end
83
+
84
+ attr_reader :permission_mapper
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkpoint
4
+ class Credential
5
+ # A Role is simple extension to the base Credential, specifying its type
6
+ # as a role and providing a conceptual object to be instantiated or passed.
7
+ #
8
+ # The most common use from outside Checkpoint will be by way of
9
+ # {Checkpoint::Query::RoleGranted}, which will ask whether a given named
10
+ # role is granted for a user. However, Role could be extended or modified
11
+ # to implement aliasing or hierarchy, for example
12
+ #
13
+ # More likely, though, is advising the resolution of Roles through a
14
+ # {Checkpoint::PermissionMapper} or implementing a custom
15
+ # {Checkpoint::Credential::Resolver}. Subclassing or monkey-patching Role
16
+ # should only be necessary if the application needs to extend the actual
17
+ # behavior of the Role objects, rather than just which ones are resolved.
18
+ class Role < Credential
19
+ TYPE = 'role'
20
+
21
+ def type
22
+ TYPE
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Checkpoint
4
+ class Credential
5
+ # A Credential::Token is an identifier object for a Credential. It includes
6
+ # a type and an identifier. A {Permit} can be granted for a Token. Concrete
7
+ # actions are resolved into a number of credentials, and those credentials'
8
+ # tokens will be checked for matching permits.
9
+ class Token
10
+ attr_reader :type, :id
11
+
12
+ # Create a new Credential representing a permission or instrument that
13
+ # represents multiple permissions.
14
+ #
15
+ # @param type [String] the application-determined type of this credential.
16
+ # For example, this might be 'permission' or 'role'.
17
+ #
18
+ # @param id [String] the application-resolvable identifier for this
19
+ # credential. For example, this might be an action to be taken or the ID
20
+ # of a role.
21
+ def initialize(type, id)
22
+ @type = type.to_s
23
+ @id = id.to_s
24
+ end
25
+
26
+ # @return [String] a URI for this credential, including its type and id
27
+ def uri
28
+ "credential://#{type}/#{id}"
29
+ end
30
+
31
+ # @return [Token] self; for convenience of taking a Credential or token
32
+ def token
33
+ self
34
+ end
35
+
36
+ # @return [String] a token suitable for granting or matching this credential
37
+ def to_s
38
+ "#{type}:#{id}"
39
+ end
40
+
41
+ # Compare with another Credential for equality. Consider them to represent
42
+ # the same credential if `other` is a credential, has the same type, and same id.
43
+ def eql?(other)
44
+ other.is_a?(Token) && type == other.type && id == other.id
45
+ end
46
+
47
+ alias == eql?
48
+ alias inspect uri
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ostruct'
4
+ require 'logger'
5
+ require 'yaml'
6
+
7
+ module Checkpoint
8
+ # Module for everything related to the Checkpoint database.
9
+ module DB
10
+ # Any error with the database that Checkpoint itself detects but cannot handle.
11
+ class DatabaseError < StandardError; end
12
+
13
+ CONNECTION_ERROR = 'The Checkpoint database is not initialized. Call initialize! first.'
14
+
15
+ ALREADY_CONNECTED = 'Already connected; refusing to connect to another database.'
16
+
17
+ MISSING_CONFIG = <<~MSG
18
+ CHECKPOINT_DATABASE_URL and DATABASE_URL are both missing and a connection
19
+ has not been configured. Cannot connect to the Checkpoint database.
20
+ See Checkpoint::DB.connect! for help.
21
+ MSG
22
+
23
+ LOAD_ERROR = <<~MSG
24
+ Error loading Checkpoint database models.
25
+ Verify connection information and that the database is migrated.
26
+ MSG
27
+
28
+ SCHEMA_HEADER = "# Checkpoint Database Version\n"
29
+
30
+ class << self
31
+ # Initialize Checkpoint
32
+ #
33
+ # This connects to the database if it has not already happened and
34
+ # requires all of the Checkpoint model classes. It is required to do the
35
+ # connection setup first because of the design decision in Sequel that
36
+ # the schema is examined at the time of extending Sequel::Model.
37
+ def initialize!
38
+ connect! unless connected?
39
+ begin
40
+ model_files.each do |file|
41
+ require_relative file
42
+ end
43
+ rescue Sequel::DatabaseError, NoMethodError => e
44
+ raise DatabaseError, LOAD_ERROR + "\n" + e.message
45
+ end
46
+ db
47
+ end
48
+
49
+ # Connect to the Checkpoint database.
50
+ #
51
+ # The default is to use the settings under {.config}, but can be
52
+ # supplied here (and they will be merged into config as a side effect).
53
+ # The keys that will be used from either source are documented here as
54
+ # the options.
55
+ #
56
+ # Only one "mode" will be used; the first of these supplied will take
57
+ # precedence:
58
+ #
59
+ # 1. An already-connected {Sequel::Database} object
60
+ # 2. A connection string
61
+ # 3. A connection options hash
62
+ #
63
+ # While Checkpoint serves as a singleton, this will raise a DatabaseError
64
+ # if already connected. Check `connected?` if you are unsure.
65
+ #
66
+ # @see {Sequel.connect}
67
+ # @param [Hash] config Optional connection config
68
+ # @option config [String] :url A Sequel database URL
69
+ # @option config [Hash] :opts A set of connection options
70
+ # @option config [Sequel::Database] :db An already-connected database;
71
+ # @return [Sequel::Database] The initialized database connection
72
+ def connect!(config = {})
73
+ raise DatabaseError, ALREADY_CONNECTED if connected?
74
+ merge_config!(config)
75
+ raise DatabaseError, MISSING_CONFIG if self.config.db.nil? && conn_opts.empty?
76
+
77
+ # We splat here because we might give one or two arguments depending
78
+ # on whether we have a string or not; to add our logger regardless.
79
+ @db = self.config.db || Sequel.connect(*conn_opts)
80
+ end
81
+
82
+ # Run any pending migrations.
83
+ # This will connect with the current config if not already connected.
84
+ def migrate!
85
+ connect! unless connected?
86
+ Sequel.extension :migration
87
+ Sequel::Migrator.run(db, File.join(__dir__, '../../db/migrations'), table: schema_table)
88
+ end
89
+
90
+ def schema_table
91
+ :checkpoint_schema
92
+ end
93
+
94
+ def schema_file
95
+ 'db/checkpoint.yml'
96
+ end
97
+
98
+ def dump_schema!
99
+ connect! unless connected?
100
+ version = db[schema_table].first.to_yaml
101
+ File.write(schema_file, SCHEMA_HEADER + version)
102
+ end
103
+
104
+ def load_schema!
105
+ connect! unless connected?
106
+ version = YAML.load_file(schema_file)[:version]
107
+ db[schema_table].delete
108
+ db[schema_table].insert(version: version)
109
+ end
110
+
111
+ def model_files
112
+ [
113
+ 'db/permit'
114
+ ]
115
+ end
116
+
117
+ # Merge url, opts, or db settings from a hash into our config
118
+ def merge_config!(config = {})
119
+ self.config.url = config[:url] if config.key?(:url)
120
+ self.config.opts = config[:opts] if config.key?(:opts)
121
+ self.config.db = config[:db] if config.key?(:db)
122
+ end
123
+
124
+ def conn_opts
125
+ log = { logger: Logger.new('db/checkpoint.log') }
126
+ url = config.url
127
+ opts = config.opts
128
+ if url
129
+ [url, log]
130
+ elsif opts
131
+ [log.merge(opts)]
132
+ else
133
+ []
134
+ end
135
+ end
136
+
137
+ def config
138
+ @config ||= OpenStruct.new(
139
+ url: ENV['CHECKPOINT_DATABASE_URL'] || ENV['DATABASE_URL']
140
+ )
141
+ end
142
+
143
+ def connected?
144
+ !@db.nil?
145
+ end
146
+
147
+ # The Checkpoint database
148
+ # @return [Sequel::Database] The connected database; be sure to call initialize! first.
149
+ def db
150
+ raise DatabaseError, CONNECTION_ERROR unless connected?
151
+ @db
152
+ end
153
+
154
+ # Forward the Sequel::Database []-syntax down to db for convenience.
155
+ # Everything else must be called on db directly, but this is nice sugar.
156
+ def [](*args)
157
+ db[*args]
158
+ end
159
+ end
160
+ end
161
+ end