checkpoint 0.2.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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