petticoat 0.2.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.
- checksums.yaml +7 -0
- data/README.md +274 -0
- data/exe/petticoat +8 -0
- data/lib/petticoat/adapter.rb +103 -0
- data/lib/petticoat/cli.rb +107 -0
- data/lib/petticoat/document.rb +216 -0
- data/lib/petticoat/installation_path.rb +29 -0
- data/lib/petticoat/installer.rb +134 -0
- data/lib/petticoat/schema.json +198 -0
- data/lib/petticoat/schema.rb +44 -0
- data/lib/petticoat/snapshot.rb +78 -0
- data/lib/petticoat/version.rb +5 -0
- data/lib/petticoat.rb +27 -0
- data/templates/bin/petticoat +8 -0
- data/templates/config/petticoat.rb +12 -0
- metadata +98 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 697674762c68bca9da5ca1d6a9a11d8293aa5b5b7acbee009d949d49180efea0
|
|
4
|
+
data.tar.gz: 6990ad9ceda48183c0f29476e6d01e31d3352ac09925f9f5c6ea03100dfd9021
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a12b5b6a52860b015aa2bc4722b5d84156ef98bf413918154c71fb2e4ff31a3e8805e8307ba74f2223a3516a610dabc75b3b2427fc02588a1d7303a311c8eb4d
|
|
7
|
+
data.tar.gz: c901e51344bd319253ff4bd61e0b13b9c40ff00afe97b7481c01581aa00c083b3c61c2e48eb6f56bf77914be34a8871a1c0557034fc501a813406ba80e2b79ae
|
data/README.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# Petticoat
|
|
2
|
+
|
|
3
|
+
Petticoat turns existing CanCanCan abilities into a small, deterministic catalogue
|
|
4
|
+
for frontend controls and navigation. It does not authorize requests. The server
|
|
5
|
+
must still check every operation.
|
|
6
|
+
|
|
7
|
+
The core is application-independent. It does not load Rails, build users,
|
|
8
|
+
discover abilities, query a database or run permission blocks.
|
|
9
|
+
|
|
10
|
+
## Supported environment
|
|
11
|
+
|
|
12
|
+
Version 0.2.0 is tested with Ruby 4.0.6 and this CanCanCan 3.6.0 fork:
|
|
13
|
+
`olistik/cancancan` at `18ce8ad800658629d4c1c2cb1382cd4794c2a5a3`.
|
|
14
|
+
The adapter checks the relevant source files as well as the version number.
|
|
15
|
+
Upstream CanCanCan 3.6.0 and other builds are **not verified** and may be rejected.
|
|
16
|
+
Do not bypass the check to make an incompatible build appear supported.
|
|
17
|
+
|
|
18
|
+
The development Gemfile pins that fork. Applications must select it themselves;
|
|
19
|
+
a gem dependency cannot select another gem's Git source.
|
|
20
|
+
|
|
21
|
+
## One selected population per document
|
|
22
|
+
|
|
23
|
+
Callers supply completed ability objects and meaningful subject identifiers.
|
|
24
|
+
Keep users with different construction contexts separate. For example, compile
|
|
25
|
+
privileged and ordinary users into separate documents when a flag changes rule
|
|
26
|
+
construction. Petticoat does not know what your application's flags mean.
|
|
27
|
+
|
|
28
|
+
Here is a complete, database-free example:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require 'petticoat'
|
|
32
|
+
|
|
33
|
+
Item = Struct.new(:owner)
|
|
34
|
+
ability = Class.new { include CanCan::Ability }.new
|
|
35
|
+
ability.can :read, Item, owner: 42
|
|
36
|
+
|
|
37
|
+
document = Petticoat::Document.new(subjects: { 'items' => Item })
|
|
38
|
+
document.add(context: 'editor', role: 'member', scenario: 'ordinary',
|
|
39
|
+
snapshot: Petticoat::Snapshot.new(ability))
|
|
40
|
+
|
|
41
|
+
catalogue = document.to_h
|
|
42
|
+
Petticoat::Schema.validate!(catalogue)
|
|
43
|
+
puts Petticoat::Document.lookup(catalogue, context: 'editor',
|
|
44
|
+
role: 'member', subject: 'items')
|
|
45
|
+
# possible
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The owner value is not exported. A conditional grant does not promise that an
|
|
49
|
+
accessible persisted record exists.
|
|
50
|
+
|
|
51
|
+
The resource context looks like:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"scope": "individual",
|
|
56
|
+
"examined_roles": ["member"],
|
|
57
|
+
"resources": {
|
|
58
|
+
"items": {"possible": ["member"], "unknown": []}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
A document's `subjects` is an array of opaque identifiers, not JSON:API metadata.
|
|
64
|
+
Do not infer identifiers by pluralizing model names. The application owns that
|
|
65
|
+
mapping and any application-specific envelope.
|
|
66
|
+
|
|
67
|
+
## Reading answers
|
|
68
|
+
|
|
69
|
+
The default `view: resources` answers whether some interaction may be available:
|
|
70
|
+
|
|
71
|
+
- `possible`: every observed setup has some potentially permitted action.
|
|
72
|
+
- `denied`: all observed setups deny all actions in this scope.
|
|
73
|
+
- `unknown`: a failed capture, unsupported behavior, disagreement or missing
|
|
74
|
+
coverage prevents a conclusion.
|
|
75
|
+
|
|
76
|
+
Resource entries contain sorted, disjoint `possible` and `unknown` role lists.
|
|
77
|
+
An examined role absent from both lists defaults to denied. A wholly denied
|
|
78
|
+
resource is omitted. An unexamined role, missing context or unregistered subject
|
|
79
|
+
is unknown, not denied. `examined_roles` includes failed attempts; examined does
|
|
80
|
+
not mean successful.
|
|
81
|
+
|
|
82
|
+
For action-specific controls:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
actions = document.to_h(view: 'actions')
|
|
86
|
+
Petticoat::Document.lookup(actions, context: 'editor', role: 'member',
|
|
87
|
+
subject: 'items', action: 'read')
|
|
88
|
+
# conditional
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The action view uses `contexts[context].roles[role][subject][action]` and returns
|
|
92
|
+
`unconditional`, `conditional`, `denied` or `unknown`. Read an explicit action
|
|
93
|
+
first, then its action `*`, then the scoped denied default. Explicit exceptions
|
|
94
|
+
win. Aliases are already expanded directionally: index does not imply literal
|
|
95
|
+
read. Custom actions keep their names. A registered subject `*` represents
|
|
96
|
+
CanCanCan's `:all`; it is not a fallback for unknown subjects.
|
|
97
|
+
|
|
98
|
+
Resource queries omit action; action queries require it. Unknown versions, missing
|
|
99
|
+
dimensions and extra query dimensions (including admin or scenario) return unknown.
|
|
100
|
+
Use separate documents to select a population; lookup does not guess or merge one.
|
|
101
|
+
|
|
102
|
+
Within each setup, any supported unconditional or conditional action makes the
|
|
103
|
+
resource possible. Otherwise an unknown action makes it unknown; otherwise denied.
|
|
104
|
+
Only then are resource answers compared across setups with the same role/context.
|
|
105
|
+
Two setups may allow different actions yet agree that some interaction is possible.
|
|
106
|
+
This is not evidence that listing, reading, or any particular endpoint will work.
|
|
107
|
+
|
|
108
|
+
## Capturing and validating
|
|
109
|
+
|
|
110
|
+
`Document#add(context:, role:, scenario:, snapshot:, scope: 'individual')`
|
|
111
|
+
records a captured setup. Use `scope: 'composed'` for an actual ordered ability
|
|
112
|
+
composition built by the caller. Never manufacture a global union of abilities.
|
|
113
|
+
Duplicate scenario identities within a context and conflicting scope raise errors.
|
|
114
|
+
|
|
115
|
+
A caller that has established a failed or unsupported setup may add
|
|
116
|
+
`snapshot: nil`; it remains unknown for every registered subject. Unexpected
|
|
117
|
+
execution failures should abort the application's export, preserving valid output.
|
|
118
|
+
|
|
119
|
+
`Document#evidence` returns sorted, redacted per-scenario action observations.
|
|
120
|
+
`Snapshot#evidence(identifier_callable)` returns redacted rule evidence in its
|
|
121
|
+
original order. Neither exports condition values, SQL or record inspect strings.
|
|
122
|
+
Condition hashes, ordinary blocks and attribute restrictions remain conditional
|
|
123
|
+
as justified by rule order. SQL, instance subjects and custom matching can be unknown.
|
|
124
|
+
Stored blocks are never executed merely to export them.
|
|
125
|
+
|
|
126
|
+
`Schema.validate!` checks the [generic schema](lib/petticoat/schema.json), related
|
|
127
|
+
membership/subject coverage and the content digest. Invalid data raises an error.
|
|
128
|
+
`Schema.validate_contexts!(contexts, subjects:, view:)` shares cross-field checks
|
|
129
|
+
with applications that have already schema-validated their own envelope.
|
|
130
|
+
|
|
131
|
+
`Petticoat.canonical_json(value)` sorts maps, not ordered rule arrays.
|
|
132
|
+
Document digests exclude themselves. The caller owns source fingerprints, output
|
|
133
|
+
paths, safe file replacement and freshness checks. Keep private diagnostics
|
|
134
|
+
separate from frontend data.
|
|
135
|
+
|
|
136
|
+
## Install the optional CLI
|
|
137
|
+
|
|
138
|
+
The core above works without the CLI. `require 'petticoat'` does not load the
|
|
139
|
+
installer or an application. Bundler exposes the gem's `exe/petticoat` as
|
|
140
|
+
`bundle exec petticoat`.
|
|
141
|
+
|
|
142
|
+
Add Petticoat to your development/test bundle with `require: false`. Select the
|
|
143
|
+
[supported CanCanCan source](#supported-environment) and install the bundle first.
|
|
144
|
+
From the application root, run:
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
BUNDLE_FROZEN=true bundle exec petticoat install --dry-run
|
|
148
|
+
BUNDLE_FROZEN=true bundle exec petticoat install
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
This creates just two application-owned files:
|
|
152
|
+
|
|
153
|
+
- `bin/petticoat`: a thin, executable Bundler wrapper that loads the gem executable.
|
|
154
|
+
- `config/petticoat.rb`: a commented integration entry to connect your own runner.
|
|
155
|
+
|
|
156
|
+
The starter is deliberately **not ready to export**. Until you configure the
|
|
157
|
+
runner, `export`, `check` and `test` fail with an actionable error and create no
|
|
158
|
+
catalogue. The installer does not infer roles, copy policies, configure databases,
|
|
159
|
+
select a test framework, run factories or boot your app. It never edits Gemfile,
|
|
160
|
+
the lockfile or existing application files.
|
|
161
|
+
|
|
162
|
+
Missing files are created; identical bytes and modes are left alone. Any content
|
|
163
|
+
or mode conflict, symlink or unsafe path aborts before writes. Dry-run writes
|
|
164
|
+
nothing. There is no `--preset`, `--force` or upgrade mode. Caught publication
|
|
165
|
+
failures roll back only unchanged files created by that attempt. Installation is
|
|
166
|
+
exclusive, not crash-atomic; rerun to finish an identical partial installation.
|
|
167
|
+
|
|
168
|
+
Freeze the bundle after dependency setup: otherwise Bundler itself can touch the
|
|
169
|
+
lockfile before a dry run reaches the installer. The wrapper and executable also
|
|
170
|
+
freeze their own setup.
|
|
171
|
+
|
|
172
|
+
## Connect your application
|
|
173
|
+
|
|
174
|
+
Edit `config/petticoat.rb`: require your application-owned runner and assign it
|
|
175
|
+
to `Petticoat::CLI.integration`. The generated comments show the shape. This is
|
|
176
|
+
trusted command configuration, not a framework initializer. Only operational
|
|
177
|
+
commands load it; help, version and install do not.
|
|
178
|
+
|
|
179
|
+
The runner can be a module, object or lambda that implements:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
def self.call(mode:, arguments:, options:, out:)
|
|
183
|
+
# Run your application's build, comparison or tests.
|
|
184
|
+
# Raise on failure, including a failed child test process.
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
| Keyword | What the CLI supplies |
|
|
189
|
+
| --- | --- |
|
|
190
|
+
| `mode` | `"export"`, `"check"` or `"test"`. |
|
|
191
|
+
| `arguments` | Unparsed application test arguments; empty for export/check. |
|
|
192
|
+
| `options[:output]` | Absolute path from an explicit `--output PATH`, if supplied. |
|
|
193
|
+
| `options[:coverage]` | Absolute path from an explicit `--diagnostics PATH`, if supplied. |
|
|
194
|
+
| `options[:seed]` | Integer from `--seed N`, if supplied. |
|
|
195
|
+
| `out` | The output IO for status messages. |
|
|
196
|
+
|
|
197
|
+
No paths, seed or test framework defaults are invented by the gem. The runner
|
|
198
|
+
must raise on failure; returning `false` or a nonzero number does not signal an
|
|
199
|
+
exit status. Normal completion means CLI exit 0. Exceptions produce exit 1;
|
|
200
|
+
unexpected exception messages are redacted. Use `Petticoat::Error` only for safe,
|
|
201
|
+
non-sensitive user-facing messages.
|
|
202
|
+
|
|
203
|
+
Your runner owns the parts that depend on your application:
|
|
204
|
+
|
|
205
|
+
1. Establish isolated test resources and disable live calls/delivery **before**
|
|
206
|
+
booting a framework or constructing factory records. A test environment name
|
|
207
|
+
alone does not prove a database is safe.
|
|
208
|
+
2. Build real, asserted scenarios and actual ordered ability compositions.
|
|
209
|
+
Supply subject identifiers and keep distinct construction populations separate.
|
|
210
|
+
Pass completed abilities to `Snapshot` and `Document`; do not copy permissions.
|
|
211
|
+
3. Clean up scenario effects. Preserve known gaps as unknown; abort unexpected
|
|
212
|
+
failures. Validate the complete catalogue before safely replacing any output.
|
|
213
|
+
4. Implement `check` as a non-writing comparison, and `test` using your own test
|
|
214
|
+
framework and chosen scope. Keep coverage diagnostics out of frontend assets.
|
|
215
|
+
|
|
216
|
+
After implementing and testing that boundary:
|
|
217
|
+
|
|
218
|
+
```sh
|
|
219
|
+
bin/petticoat test
|
|
220
|
+
bin/petticoat export
|
|
221
|
+
bin/petticoat check
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Export/check also accept `--output PATH`, `--diagnostics PATH` and `--seed N`.
|
|
225
|
+
The CLI dispatches these operations; it does not implement database isolation,
|
|
226
|
+
file publication or freshness for you.
|
|
227
|
+
|
|
228
|
+
```text
|
|
229
|
+
Gem: bin wrapper template + CLI + capture / reduction / lookup / schema
|
|
230
|
+
|
|
|
231
|
+
loads config/petticoat.rb
|
|
232
|
+
|
|
|
233
|
+
App: explicit runner + scenarios / composition / subjects / isolation / output
|
|
234
|
+
|
|
|
235
|
+
explicit build using the core
|
|
236
|
+
|
|
|
237
|
+
Frontend: generated JSON hints Server: existing authorization checks
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Commit the wrapper, completed config, application runner, its tests and any
|
|
241
|
+
generated assets in the application repository. Keep application-specific
|
|
242
|
+
onboarding and safety instructions there too. A fresh application needs its own
|
|
243
|
+
integration; the gem cannot safely infer one.
|
|
244
|
+
|
|
245
|
+
For upgrades, update the dependency and review your runner against this contract.
|
|
246
|
+
Do not rerun install over a customized config. Application integrations are not
|
|
247
|
+
bundled presets and are never overwritten by a gem update. Ordinary Ability
|
|
248
|
+
changes need a fresh export, not parallel permission declarations. Add scenarios
|
|
249
|
+
when new roles, construction-time branches or composition contexts require them.
|
|
250
|
+
|
|
251
|
+
## Develop and build locally
|
|
252
|
+
|
|
253
|
+
Use the Ruby version in `.ruby-version`:
|
|
254
|
+
|
|
255
|
+
```sh
|
|
256
|
+
bundle install --local
|
|
257
|
+
bundle exec rspec
|
|
258
|
+
bundle exec rubocop
|
|
259
|
+
gem build petticoat.gemspec
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
If a locked dependency is not installed locally, dependency setup may need network
|
|
263
|
+
access. Tests use real CanCanCan and synthetic subjects, not Rails or factories.
|
|
264
|
+
They also execute the Ruby example above and test the optional CLI and installer
|
|
265
|
+
against filesystem failures and an application-supplied callable in a fresh process.
|
|
266
|
+
Application-level factory, isolation and publication tests belong to each application.
|
|
267
|
+
|
|
268
|
+
This is a pre-release, Git-sourced gem, not a published RubyGems release.
|
|
269
|
+
No open-source license or public release is provided; `Nonstandard`
|
|
270
|
+
metadata does not grant redistribution rights. Publication is deliberately disabled
|
|
271
|
+
through an invalid `allowed_push_host`. Choose licensing, verify the name, and
|
|
272
|
+
authorize distribution separately before any release.
|
|
273
|
+
|
|
274
|
+
Made with ❤️ by [olistik](https://olisti.co)
|
data/exe/petticoat
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petticoat
|
|
4
|
+
# All access to undocumented CanCanCan internals lives here. Only private
|
|
5
|
+
# copies receive the gem's relevance/alias-cache mutations.
|
|
6
|
+
class Adapter
|
|
7
|
+
ID = 'cancancan-3.6.0-18ce8ad80065'
|
|
8
|
+
FILES = {
|
|
9
|
+
'ability.rb' => '6864743679252c07b750630ca4ebbad94117d6367ef6c33d7132a0e1ce2d5bbc',
|
|
10
|
+
'ability/rules.rb' => '4a7f96dd94b606e904718642b1a0a97efa3000be6396fb5f1150be005b02032b',
|
|
11
|
+
'ability/actions.rb' => '97c79d145b0e694cb273c4b7f3915426ead55546295d1ba92d6bb0cdfe08c3f7',
|
|
12
|
+
'rule.rb' => '7a4b0ce56f001a29a66c1051928732a88c893060ed1e11510fa2e1d3c31e7d18',
|
|
13
|
+
'relevant.rb' => 'dbe8ea801fd50a2e6fbb87a65825febb6affdee367c6a52706340bf1a91e55a1',
|
|
14
|
+
'conditions_matcher.rb' => 'f324a2a78788241c6ea53aecd3b9f48b947d42551db3eba84e6f9226c33351b7',
|
|
15
|
+
'class_matcher.rb' => 'b26f464494c2ff1551c4fa004d28fc4c82cad4cacbb04088e55971d47af489e2'
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
Shell = Class.new { include CanCan::Ability }
|
|
19
|
+
COPIED_FIELDS = %i[actions subjects attributes].freeze
|
|
20
|
+
METHOD_OWNERS = {
|
|
21
|
+
can?: CanCan::Ability,
|
|
22
|
+
alternative_subjects: CanCan::Ability,
|
|
23
|
+
rules: CanCan::Ability::Rules,
|
|
24
|
+
relevant_rules: CanCan::Ability::Rules,
|
|
25
|
+
possible_relevant_rules: CanCan::Ability::Rules,
|
|
26
|
+
expand_actions: CanCan::Ability::Actions,
|
|
27
|
+
aliased_actions: CanCan::Ability::Actions
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :rules, :aliases, :issues
|
|
31
|
+
|
|
32
|
+
def self.verify!
|
|
33
|
+
raise Error, 'Unsupported CanCanCan version' unless CanCan::VERSION == '3.6.0'
|
|
34
|
+
return if @verified
|
|
35
|
+
|
|
36
|
+
root = File.join(Gem.loaded_specs.fetch('cancancan').full_gem_path, 'lib/cancan')
|
|
37
|
+
unless FILES.all? { |file, digest| Digest::SHA256.file(File.join(root, file)).hexdigest == digest }
|
|
38
|
+
raise Error, 'Unsupported CanCanCan source; review the manifest adapter before exporting'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@verified = true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(ability)
|
|
45
|
+
self.class.verify!
|
|
46
|
+
@shell = Shell.new
|
|
47
|
+
@issues = semantic_issues(ability)
|
|
48
|
+
@aliases = copy_aliases(ability)
|
|
49
|
+
validate_aliases!
|
|
50
|
+
@shell.instance_variable_set(:@aliased_actions, @aliases.transform_values(&:dup))
|
|
51
|
+
@rules = copy_rules(ability)
|
|
52
|
+
cache = ability.instance_variable_get(:@expanded_actions) || {}
|
|
53
|
+
@shell.instance_variable_set(:@expanded_actions, cache.to_h { |key, value| [key.dup, value.dup] })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def relevant(action, subject)
|
|
57
|
+
@shell.send(:relevant_rules, action, subject)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def actions
|
|
61
|
+
(rules.flat_map(&:actions) + aliases.keys + aliases.values.flatten).uniq
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def unsupported_subjects?
|
|
65
|
+
rules.any? do |rule|
|
|
66
|
+
rule.subjects.empty? || rule.subjects.any? { |subject| !subject.is_a?(Module) && !subject.is_a?(Symbol) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def copy_aliases(ability)
|
|
73
|
+
source = ability.instance_variable_get(:@aliased_actions) || @shell.aliased_actions
|
|
74
|
+
source.to_h { |key, values| [key, values.dup] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def semantic_issues(ability)
|
|
78
|
+
METHOD_OWNERS.filter_map { |name, owner| "custom_#{name}" unless ability.method(name).owner == owner }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def copy_rules(ability)
|
|
82
|
+
(ability.instance_variable_get(:@rules) || []).map do |rule|
|
|
83
|
+
copy = rule.dup
|
|
84
|
+
COPIED_FIELDS.each do |field|
|
|
85
|
+
copy.instance_variable_set(:"@#{field}", rule.public_send(field).dup)
|
|
86
|
+
end
|
|
87
|
+
@shell.send(:add_rule, copy)
|
|
88
|
+
copy
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def validate_aliases!
|
|
93
|
+
@aliases.each_key { |action| visit_alias(action, []) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def visit_alias(action, path)
|
|
97
|
+
raise Error, 'Unsupported alias cycle' if path.include?(action)
|
|
98
|
+
raise Error, 'Unsupported action identifier' unless action.is_a?(Symbol)
|
|
99
|
+
|
|
100
|
+
@aliases.fetch(action, []).each { |child| visit_alias(child, path + [action]) }
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
require_relative '../petticoat'
|
|
5
|
+
|
|
6
|
+
module Petticoat
|
|
7
|
+
# Optional command entry. Loading the core never loads an application's setup.
|
|
8
|
+
class CLI
|
|
9
|
+
HELP = <<~TEXT
|
|
10
|
+
Usage: petticoat install [--dry-run]
|
|
11
|
+
petticoat export|check [--output PATH] [--diagnostics PATH] [--seed N]
|
|
12
|
+
petticoat test [application test arguments]
|
|
13
|
+
petticoat --help|--version
|
|
14
|
+
|
|
15
|
+
Run from the application root. Installation creates missing files only.
|
|
16
|
+
Export, check and test use your runner in config/petticoat.rb.
|
|
17
|
+
See the Petticoat README for the integration contract.
|
|
18
|
+
TEXT
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
attr_accessor :integration
|
|
22
|
+
|
|
23
|
+
def run(arguments, root: Dir.pwd, out: $stdout, err: $stderr)
|
|
24
|
+
self.integration = nil
|
|
25
|
+
new(arguments.dup, root, out).run
|
|
26
|
+
rescue OptionParser::ParseError
|
|
27
|
+
err.puts 'Petticoat: invalid command options; run petticoat --help.'
|
|
28
|
+
1
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
# Only our own explicit messages may reach the terminal.
|
|
31
|
+
message = e.is_a?(Petticoat::Error) ||
|
|
32
|
+
(e.instance_of?(RuntimeError) && e.message.start_with?('Petticoat'))
|
|
33
|
+
err.puts(message ? e.message : "Petticoat command failed (#{e.class}).")
|
|
34
|
+
1
|
|
35
|
+
ensure
|
|
36
|
+
self.integration = nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(arguments, root, out)
|
|
41
|
+
@arguments = arguments
|
|
42
|
+
@root = File.expand_path(root)
|
|
43
|
+
@out = out
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def run
|
|
47
|
+
mode = @arguments.shift
|
|
48
|
+
case mode
|
|
49
|
+
when nil, '--help', '-h' then display(HELP)
|
|
50
|
+
when '--version', '-v' then display(Petticoat::VERSION)
|
|
51
|
+
when 'install' then install
|
|
52
|
+
when 'export', 'check', 'test' then operate(mode)
|
|
53
|
+
else raise Error, 'Petticoat: unknown command; run petticoat --help.'
|
|
54
|
+
end
|
|
55
|
+
0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def display(message)
|
|
61
|
+
reject_arguments!
|
|
62
|
+
@out.puts message
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def install
|
|
66
|
+
require_relative 'installer'
|
|
67
|
+
options = { dry_run: false }
|
|
68
|
+
parser = OptionParser.new do |item|
|
|
69
|
+
item.on('--dry-run') { options[:dry_run] = true }
|
|
70
|
+
end
|
|
71
|
+
parser.parse!(@arguments)
|
|
72
|
+
reject_arguments!
|
|
73
|
+
Installer.new(root: @root).call(dry_run: options[:dry_run], out: @out)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def operate(mode)
|
|
77
|
+
options = mode == 'test' ? {} : export_options
|
|
78
|
+
config = File.join(@root, 'config/petticoat.rb')
|
|
79
|
+
unless File.file?(config)
|
|
80
|
+
raise Error, 'Petticoat: missing config/petticoat.rb; run petticoat install, ' \
|
|
81
|
+
'then configure your application runner.'
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
load config
|
|
85
|
+
unless self.class.integration.respond_to?(:call)
|
|
86
|
+
raise Error, 'Petticoat: config/petticoat.rb must select a callable CLI.integration.'
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
self.class.integration.call(mode: mode, arguments: @arguments, options: options, out: @out)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def export_options
|
|
93
|
+
options = {}
|
|
94
|
+
OptionParser.new do |parser|
|
|
95
|
+
parser.on('--output PATH') { |value| options[:output] = File.expand_path(value, @root) }
|
|
96
|
+
parser.on('--diagnostics PATH') { |value| options[:coverage] = File.expand_path(value, @root) }
|
|
97
|
+
parser.on('--seed N', Integer) { |value| options[:seed] = value }
|
|
98
|
+
end.parse!(@arguments)
|
|
99
|
+
reject_arguments!
|
|
100
|
+
options
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def reject_arguments!
|
|
104
|
+
raise Error, 'Petticoat: unexpected command arguments; run petticoat --help.' unless @arguments.empty?
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petticoat
|
|
4
|
+
class Document
|
|
5
|
+
SCHEMA_VERSION = 1
|
|
6
|
+
VIEWS = %w[resources actions].freeze
|
|
7
|
+
DEFAULT_ACTIONS = %w[read index show create new update edit destroy manage].freeze
|
|
8
|
+
DEFAULTS = { 'state' => 'denied', 'scope' => 'registered_subjects_in_present_entries' }.freeze
|
|
9
|
+
private_constant :DEFAULTS
|
|
10
|
+
|
|
11
|
+
def initialize(subjects:, actions: DEFAULT_ACTIONS)
|
|
12
|
+
@subjects = subjects
|
|
13
|
+
@actions = actions
|
|
14
|
+
@observations = {}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# One document represents one caller-selected construction population.
|
|
18
|
+
def add(context:, role:, scenario:, snapshot:, scope: 'individual')
|
|
19
|
+
entry = (@observations[context] ||= { scope:, snapshots: {} })
|
|
20
|
+
raise Error, 'Conflicting context scope' unless entry[:scope] == scope
|
|
21
|
+
raise Error, 'Duplicate scenario in context' if entry[:snapshots].key?(scenario)
|
|
22
|
+
|
|
23
|
+
entry[:snapshots][scenario] = { role:, snapshot: }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def to_h(view: 'resources')
|
|
27
|
+
raise Error, 'Unsupported catalogue view' unless VIEWS.include?(view)
|
|
28
|
+
|
|
29
|
+
actions = all_actions
|
|
30
|
+
payload = {
|
|
31
|
+
'schema_version' => SCHEMA_VERSION,
|
|
32
|
+
'view' => view,
|
|
33
|
+
'adapter' => Adapter::ID,
|
|
34
|
+
'scope' => 'observed_ability_construction',
|
|
35
|
+
'basis' => 'observed_scenarios',
|
|
36
|
+
'defaults' => DEFAULTS,
|
|
37
|
+
'subjects' => @subjects.keys.sort,
|
|
38
|
+
'contexts' => @observations.transform_values do |entry|
|
|
39
|
+
context_payload(entry[:scope], entry[:snapshots], actions, view)
|
|
40
|
+
end
|
|
41
|
+
}
|
|
42
|
+
payload['digest'] = Digest::SHA256.hexdigest(Petticoat.canonical_json(payload))
|
|
43
|
+
Petticoat.canonical(payload)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Developer evidence, not another frontend catalogue. Values stay redacted.
|
|
47
|
+
def evidence
|
|
48
|
+
actions = all_actions
|
|
49
|
+
observations =
|
|
50
|
+
@observations.flat_map do |context, entry|
|
|
51
|
+
entry[:snapshots].map do |scenario, observation|
|
|
52
|
+
payload = snapshot_payload(observation, actions)
|
|
53
|
+
payload.merge('context' => context,
|
|
54
|
+
'scope' => entry[:scope],
|
|
55
|
+
'scenario' => scenario,
|
|
56
|
+
'states' => sparse_states(payload.fetch('states')))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
Petticoat.canonical(observations.sort_by { |item| item.values_at('context', 'scenario') })
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.lookup(payload, **query)
|
|
63
|
+
return 'unknown' unless payload.is_a?(Hash)
|
|
64
|
+
return 'unknown' unless compatible_query?(payload, query)
|
|
65
|
+
return 'unknown' unless registered_subject?(payload, query.fetch(:subject))
|
|
66
|
+
|
|
67
|
+
context = payload['contexts']&.dig(query.fetch(:context))
|
|
68
|
+
return 'unknown' unless context.is_a?(Hash)
|
|
69
|
+
|
|
70
|
+
payload.fetch('view') == 'resources' ? lookup_resource(context, query) : lookup_actions(context, query)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.lookup_actions(context, query)
|
|
74
|
+
entry = context.dig('roles', query.fetch(:role))
|
|
75
|
+
return 'unknown' unless entry.is_a?(Hash)
|
|
76
|
+
|
|
77
|
+
actions = entry.fetch(query.fetch(:subject), {})
|
|
78
|
+
actions.fetch(query.fetch(:action), actions.fetch('*', DEFAULTS.fetch('state')))
|
|
79
|
+
end
|
|
80
|
+
private_class_method :lookup_actions
|
|
81
|
+
|
|
82
|
+
def self.registered_subject?(payload, subject)
|
|
83
|
+
payload['subjects'].is_a?(Array) && payload['subjects'].include?(subject)
|
|
84
|
+
end
|
|
85
|
+
private_class_method :registered_subject?
|
|
86
|
+
|
|
87
|
+
def self.compatible_query?(payload, query)
|
|
88
|
+
payload.values_at('schema_version', 'basis', 'defaults') ==
|
|
89
|
+
[SCHEMA_VERSION, 'observed_scenarios', DEFAULTS] &&
|
|
90
|
+
(query.keys - %i[context role subject action]).empty? &&
|
|
91
|
+
%i[context role subject].all? { |key| query[key].is_a?(String) } && compatible_view_query?(payload, query)
|
|
92
|
+
end
|
|
93
|
+
private_class_method :compatible_query?
|
|
94
|
+
|
|
95
|
+
def self.compatible_view_query?(payload, query)
|
|
96
|
+
case payload['view']
|
|
97
|
+
when 'resources' then !query.key?(:action)
|
|
98
|
+
when 'actions' then query[:action].is_a?(String)
|
|
99
|
+
else false
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
private_class_method :compatible_view_query?
|
|
103
|
+
|
|
104
|
+
def self.lookup_resource(context, query)
|
|
105
|
+
role = query.fetch(:role)
|
|
106
|
+
examined = context['examined_roles']
|
|
107
|
+
resources = context['resources']
|
|
108
|
+
return 'unknown' unless examined.is_a?(Array) && examined.include?(role) && resources.is_a?(Hash)
|
|
109
|
+
return DEFAULTS.fetch('state') unless resources.key?(query.fetch(:subject))
|
|
110
|
+
|
|
111
|
+
resource_membership(resources.fetch(query.fetch(:subject)), role)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :lookup_resource
|
|
114
|
+
|
|
115
|
+
def self.resource_membership(entry, role)
|
|
116
|
+
return 'unknown' unless entry.is_a?(Hash) && entry['possible'].is_a?(Array) && entry['unknown'].is_a?(Array)
|
|
117
|
+
return 'unknown' if entry.fetch('unknown').include?(role)
|
|
118
|
+
return 'possible' if entry.fetch('possible').include?(role)
|
|
119
|
+
|
|
120
|
+
DEFAULTS.fetch('state')
|
|
121
|
+
end
|
|
122
|
+
private_class_method :resource_membership
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def all_actions
|
|
127
|
+
snapshots = @observations.values.flat_map { |context| context[:snapshots].values }
|
|
128
|
+
observed = snapshots.flat_map { |entry| entry[:snapshot]&.actions || [] }
|
|
129
|
+
(@actions + observed + ['*']).uniq.sort
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def context_payload(scope, observations, actions, view)
|
|
133
|
+
snapshots = observations.values.map { |observation| snapshot_payload(observation, actions) }
|
|
134
|
+
roles =
|
|
135
|
+
snapshots.group_by { |snapshot| snapshot.fetch('role') }
|
|
136
|
+
.transform_values do |group|
|
|
137
|
+
view == 'resources' ? resource_states(group) : sparse_states(aggregate(group, actions))
|
|
138
|
+
end
|
|
139
|
+
# Agreement uses complete tables. Only the exported representation is sparse.
|
|
140
|
+
view == 'resources' ? resource_context(scope, roles) : { 'scope' => scope, 'roles' => roles }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Invert already-agreed states, without losing examined-but-denied roles.
|
|
144
|
+
def resource_context(scope, roles)
|
|
145
|
+
resources = {}
|
|
146
|
+
roles.sort.each do |role, states|
|
|
147
|
+
states.each do |subject, state|
|
|
148
|
+
entry = (resources[subject] ||= { 'possible' => [], 'unknown' => [] })
|
|
149
|
+
entry.fetch(state) << role
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
{ 'scope' => scope, 'examined_roles' => roles.keys.sort, 'resources' => resources }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def snapshot_payload(observation, actions)
|
|
156
|
+
snapshot = observation[:snapshot]
|
|
157
|
+
{
|
|
158
|
+
'role' => observation[:role],
|
|
159
|
+
'coverage' => snapshot ? 'observed' : 'unknown',
|
|
160
|
+
'aliases' => snapshot&.aliases || {},
|
|
161
|
+
'states' => states(snapshot, actions)
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Ask whether any interaction is possible within EACH scenario first. Two
|
|
166
|
+
# scenarios may allow different actions while agreeing at resource level.
|
|
167
|
+
def resource_states(observations)
|
|
168
|
+
@subjects.filter_map do |identifier, _subject|
|
|
169
|
+
values = observations.map { |snapshot| interaction(snapshot.fetch('states').fetch(identifier)) }
|
|
170
|
+
.uniq
|
|
171
|
+
value = values.size == 1 ? values.first : 'unknown'
|
|
172
|
+
[identifier, value] unless value == DEFAULTS.fetch('state')
|
|
173
|
+
end.to_h
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def interaction(actions)
|
|
177
|
+
return 'possible' if actions.values.any? { |state| %w[unconditional conditional].include?(state) }
|
|
178
|
+
return 'unknown' if actions.value?('unknown')
|
|
179
|
+
|
|
180
|
+
'denied'
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def states(snapshot, actions)
|
|
184
|
+
@subjects.to_h do |identifier, subject|
|
|
185
|
+
values = actions.to_h { |action| [action, snapshot ? snapshot.state(action, subject) : 'unknown'] }
|
|
186
|
+
[identifier, compact(values)]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def aggregate(observations, actions)
|
|
191
|
+
@subjects.to_h do |identifier, _subject|
|
|
192
|
+
values = actions.to_h { |action| [action, agreement(observations, identifier, action)] }
|
|
193
|
+
[identifier, compact(values)]
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def agreement(observations, identifier, action)
|
|
198
|
+
values = observations.map do |snapshot|
|
|
199
|
+
table = snapshot.fetch('states').fetch(identifier)
|
|
200
|
+
table.fetch(action, table.fetch('*'))
|
|
201
|
+
end.uniq
|
|
202
|
+
values.size == 1 ? values.first : 'unknown'
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def compact(values)
|
|
206
|
+
values.select { |action, value| action == '*' || value != values.fetch('*') }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def sparse_states(states)
|
|
210
|
+
states.filter_map do |subject, actions|
|
|
211
|
+
sparse = actions.reject { |action, value| action == '*' && value == DEFAULTS.fetch('state') }
|
|
212
|
+
[subject, sparse] unless sparse.empty?
|
|
213
|
+
end.to_h
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petticoat
|
|
4
|
+
# Reject symlinks and non-directory parents inside the selected checkout.
|
|
5
|
+
class InstallationPath
|
|
6
|
+
def initialize(root, relative)
|
|
7
|
+
parts = relative.split('/')
|
|
8
|
+
if relative.empty? || relative.start_with?('/') || parts.any? { |part| ['', '.', '..'].include?(part) } ||
|
|
9
|
+
relative.include?("\0") || relative.include?('\\')
|
|
10
|
+
raise Error, 'Petticoat: unsafe installation path.'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
@root = File.realpath(root)
|
|
14
|
+
@parts = parts
|
|
15
|
+
@relative = relative
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def checked
|
|
19
|
+
path = @root
|
|
20
|
+
@parts.each_with_index do |part, index|
|
|
21
|
+
path = File.join(path, part)
|
|
22
|
+
invalid = File.symlink?(path) ||
|
|
23
|
+
(File.exist?(path) && (index == @parts.size - 1 ? !File.file?(path) : !File.directory?(path)))
|
|
24
|
+
raise Error, "Petticoat: unsafe installation path: #{@relative}." if invalid
|
|
25
|
+
end
|
|
26
|
+
path
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'tempfile'
|
|
5
|
+
require_relative '../petticoat'
|
|
6
|
+
require_relative 'installation_path'
|
|
7
|
+
|
|
8
|
+
module Petticoat
|
|
9
|
+
# Create-only publication: a preflight conflict never causes partial installation.
|
|
10
|
+
class Installer
|
|
11
|
+
FILES = { 'bin/petticoat' => 0o755, 'config/petticoat.rb' => 0o644 }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(root:)
|
|
14
|
+
@root = File.realpath(root)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(dry_run: false, out: $stdout)
|
|
18
|
+
entries = plan
|
|
19
|
+
entries.each { |entry| out.puts "#{entry.fetch(:state)} #{entry.fetch(:relative)}" }
|
|
20
|
+
additions = entries.select { |entry| entry.fetch(:state) == 'create' }
|
|
21
|
+
publish(additions) unless dry_run || additions.empty?
|
|
22
|
+
out.puts(dry_run ? 'Petticoat dry run: no files written.' : 'Petticoat starter installed.')
|
|
23
|
+
out.puts 'Next: edit config/petticoat.rb.' unless dry_run
|
|
24
|
+
out.puts 'See the Petticoat README. Install does not configure or start your app, or generate catalogues.'
|
|
25
|
+
entries
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def files
|
|
31
|
+
directory = File.expand_path('../../templates', __dir__)
|
|
32
|
+
FILES.to_h do |path, mode|
|
|
33
|
+
source = InstallationPath.new(directory, path).checked
|
|
34
|
+
[path, { bytes: File.binread(source), mode: mode }]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def plan
|
|
39
|
+
entries = files.sort.map { |relative, file| entry(relative, file) }
|
|
40
|
+
conflicts = entries.select { |item| item.fetch(:state) == 'conflict' }
|
|
41
|
+
unless conflicts.empty?
|
|
42
|
+
names = conflicts.map { |item| item.fetch(:relative) }.join(', ')
|
|
43
|
+
raise Error, "Petticoat installation conflicts: #{names}. " \
|
|
44
|
+
'No files written. Review existing integration files; there is no --force.'
|
|
45
|
+
end
|
|
46
|
+
entries
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def entry(relative, file)
|
|
50
|
+
raise Error, 'Petticoat: target is outside the two-file starter.' unless FILES.key?(relative)
|
|
51
|
+
|
|
52
|
+
path = InstallationPath.new(@root, relative).checked
|
|
53
|
+
state = if !File.exist?(path)
|
|
54
|
+
'create'
|
|
55
|
+
elsif identical?(path, file)
|
|
56
|
+
'unchanged'
|
|
57
|
+
else
|
|
58
|
+
'conflict'
|
|
59
|
+
end
|
|
60
|
+
file.merge(relative: relative, path: path, state: state)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def identical?(path, file)
|
|
64
|
+
File.binread(path) == file.fetch(:bytes) && (File.stat(path).mode & 0o777) == file.fetch(:mode)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def publish(entries)
|
|
68
|
+
staged = []
|
|
69
|
+
installed = []
|
|
70
|
+
directories = []
|
|
71
|
+
begin
|
|
72
|
+
entries.each { |entry| staged << stage(entry) }
|
|
73
|
+
staged.each { |entry| install_file(entry, installed, directories) }
|
|
74
|
+
rescue StandardError
|
|
75
|
+
rollback(installed, directories)
|
|
76
|
+
raise
|
|
77
|
+
ensure
|
|
78
|
+
staged.each { |entry| entry.fetch(:temporary).close! }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def stage(entry)
|
|
83
|
+
file = Tempfile.new('.petticoat-install-', @root)
|
|
84
|
+
file.binmode
|
|
85
|
+
file.write(entry.fetch(:bytes))
|
|
86
|
+
file.flush
|
|
87
|
+
file.fsync
|
|
88
|
+
file.chmod(entry.fetch(:mode))
|
|
89
|
+
entry.merge(temporary: file)
|
|
90
|
+
rescue StandardError
|
|
91
|
+
file&.close!
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def install_file(entry, installed, directories)
|
|
96
|
+
InstallationPath.new(@root, entry.fetch(:relative)).checked
|
|
97
|
+
make_parents(File.dirname(entry.fetch(:path)), directories)
|
|
98
|
+
# Exclusive creation, including when another writer races the preflight.
|
|
99
|
+
File.link(entry.fetch(:temporary).path, entry.fetch(:path))
|
|
100
|
+
installed << entry.merge(identity: File.stat(entry.fetch(:path)).ino)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def make_parents(directory, created)
|
|
104
|
+
return if directory == @root || File.directory?(directory)
|
|
105
|
+
|
|
106
|
+
make_parents(File.dirname(directory), created)
|
|
107
|
+
Dir.mkdir(directory)
|
|
108
|
+
created << { path: directory, identity: File.stat(directory).ino }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rollback(installed, directories)
|
|
112
|
+
installed.reverse_each { |entry| remove_owned_file(entry) }
|
|
113
|
+
directories.reverse_each do |entry|
|
|
114
|
+
path = entry.fetch(:path)
|
|
115
|
+
next unless File.directory?(path) && File.realpath(path) == path &&
|
|
116
|
+
File.stat(path).ino == entry.fetch(:identity)
|
|
117
|
+
|
|
118
|
+
Dir.rmdir(path)
|
|
119
|
+
rescue SystemCallError
|
|
120
|
+
# Preserve nonempty or concurrently changed directories.
|
|
121
|
+
next
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def remove_owned_file(entry)
|
|
126
|
+
path = InstallationPath.new(@root, entry.fetch(:relative)).checked
|
|
127
|
+
return unless File.file?(path) && File.stat(path).ino == entry.fetch(:identity) && identical?(path, entry)
|
|
128
|
+
|
|
129
|
+
File.unlink(path)
|
|
130
|
+
rescue StandardError
|
|
131
|
+
warn 'Petticoat: installation rollback could not remove an owned file; inspect the planned paths.'
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-04/schema#",
|
|
3
|
+
"title": "Petticoat observed capability catalogue",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"schema_version": {
|
|
7
|
+
"enum": [
|
|
8
|
+
1
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
"adapter": {
|
|
12
|
+
"enum": [
|
|
13
|
+
"cancancan-3.6.0-18ce8ad80065"
|
|
14
|
+
]
|
|
15
|
+
},
|
|
16
|
+
"scope": {
|
|
17
|
+
"enum": [
|
|
18
|
+
"observed_ability_construction"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"digest": {
|
|
22
|
+
"$ref": "#/definitions/digest"
|
|
23
|
+
},
|
|
24
|
+
"subjects": {
|
|
25
|
+
"$ref": "#/definitions/strings"
|
|
26
|
+
},
|
|
27
|
+
"contexts": {
|
|
28
|
+
"type": "object"
|
|
29
|
+
},
|
|
30
|
+
"defaults": {
|
|
31
|
+
"enum": [
|
|
32
|
+
{
|
|
33
|
+
"state": "denied",
|
|
34
|
+
"scope": "registered_subjects_in_present_entries"
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"basis": {
|
|
39
|
+
"enum": [
|
|
40
|
+
"observed_scenarios"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"view": {
|
|
44
|
+
"enum": [
|
|
45
|
+
"resources",
|
|
46
|
+
"actions"
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"required": [
|
|
51
|
+
"schema_version",
|
|
52
|
+
"adapter",
|
|
53
|
+
"scope",
|
|
54
|
+
"digest",
|
|
55
|
+
"subjects",
|
|
56
|
+
"contexts",
|
|
57
|
+
"defaults",
|
|
58
|
+
"basis",
|
|
59
|
+
"view"
|
|
60
|
+
],
|
|
61
|
+
"additionalProperties": false,
|
|
62
|
+
"definitions": {
|
|
63
|
+
"digest": {
|
|
64
|
+
"type": "string",
|
|
65
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
66
|
+
},
|
|
67
|
+
"strings": {
|
|
68
|
+
"type": "array",
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"uniqueItems": true
|
|
73
|
+
},
|
|
74
|
+
"actions": {
|
|
75
|
+
"type": "object",
|
|
76
|
+
"additionalProperties": {
|
|
77
|
+
"enum": [
|
|
78
|
+
"unconditional",
|
|
79
|
+
"conditional",
|
|
80
|
+
"denied",
|
|
81
|
+
"unknown"
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"states": {
|
|
86
|
+
"type": "object",
|
|
87
|
+
"additionalProperties": {
|
|
88
|
+
"$ref": "#/definitions/actions"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"resources_contexts": {
|
|
92
|
+
"type": "object",
|
|
93
|
+
"additionalProperties": {
|
|
94
|
+
"$ref": "#/definitions/resources_context"
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"actions_contexts": {
|
|
98
|
+
"type": "object",
|
|
99
|
+
"additionalProperties": {
|
|
100
|
+
"$ref": "#/definitions/actions_context"
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
"actions_context": {
|
|
104
|
+
"type": "object",
|
|
105
|
+
"properties": {
|
|
106
|
+
"scope": {
|
|
107
|
+
"enum": [
|
|
108
|
+
"individual",
|
|
109
|
+
"composed"
|
|
110
|
+
]
|
|
111
|
+
},
|
|
112
|
+
"roles": {
|
|
113
|
+
"type": "object",
|
|
114
|
+
"additionalProperties": {
|
|
115
|
+
"$ref": "#/definitions/states"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
"required": [
|
|
120
|
+
"scope",
|
|
121
|
+
"roles"
|
|
122
|
+
],
|
|
123
|
+
"additionalProperties": false
|
|
124
|
+
},
|
|
125
|
+
"resource_roles": {
|
|
126
|
+
"type": "object",
|
|
127
|
+
"properties": {
|
|
128
|
+
"possible": {
|
|
129
|
+
"$ref": "#/definitions/strings"
|
|
130
|
+
},
|
|
131
|
+
"unknown": {
|
|
132
|
+
"$ref": "#/definitions/strings"
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
"required": [
|
|
136
|
+
"possible",
|
|
137
|
+
"unknown"
|
|
138
|
+
],
|
|
139
|
+
"additionalProperties": false
|
|
140
|
+
},
|
|
141
|
+
"resources_context": {
|
|
142
|
+
"type": "object",
|
|
143
|
+
"properties": {
|
|
144
|
+
"scope": {
|
|
145
|
+
"enum": [
|
|
146
|
+
"individual",
|
|
147
|
+
"composed"
|
|
148
|
+
]
|
|
149
|
+
},
|
|
150
|
+
"examined_roles": {
|
|
151
|
+
"$ref": "#/definitions/strings"
|
|
152
|
+
},
|
|
153
|
+
"resources": {
|
|
154
|
+
"type": "object",
|
|
155
|
+
"additionalProperties": {
|
|
156
|
+
"$ref": "#/definitions/resource_roles"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
"required": [
|
|
161
|
+
"scope",
|
|
162
|
+
"examined_roles",
|
|
163
|
+
"resources"
|
|
164
|
+
],
|
|
165
|
+
"additionalProperties": false
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
"allOf": [
|
|
169
|
+
{
|
|
170
|
+
"oneOf": [
|
|
171
|
+
{
|
|
172
|
+
"properties": {
|
|
173
|
+
"view": {
|
|
174
|
+
"enum": [
|
|
175
|
+
"resources"
|
|
176
|
+
]
|
|
177
|
+
},
|
|
178
|
+
"contexts": {
|
|
179
|
+
"$ref": "#/definitions/resources_contexts"
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
"properties": {
|
|
185
|
+
"view": {
|
|
186
|
+
"enum": [
|
|
187
|
+
"actions"
|
|
188
|
+
]
|
|
189
|
+
},
|
|
190
|
+
"contexts": {
|
|
191
|
+
"$ref": "#/definitions/actions_contexts"
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
}
|
|
197
|
+
]
|
|
198
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json-schema'
|
|
4
|
+
|
|
5
|
+
module Petticoat
|
|
6
|
+
module Schema
|
|
7
|
+
PATH = File.expand_path('schema.json', __dir__).freeze
|
|
8
|
+
|
|
9
|
+
def self.validate!(payload)
|
|
10
|
+
JSON::Validator.validate!(PATH, payload, validate_schema: true)
|
|
11
|
+
validate_contexts!(payload.fetch('contexts'), subjects: payload.fetch('subjects'), view: payload.fetch('view'))
|
|
12
|
+
expected = Digest::SHA256.hexdigest(Petticoat.canonical_json(payload.except('digest')))
|
|
13
|
+
raise Error, 'Content digest mismatch' unless payload.fetch('digest') == expected
|
|
14
|
+
|
|
15
|
+
payload
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The shape is schema-validated first; these invariants relate sibling fields.
|
|
19
|
+
def self.validate_contexts!(contexts, subjects:, view:)
|
|
20
|
+
contexts.each_value do |context|
|
|
21
|
+
if view == 'resources'
|
|
22
|
+
validate_resource_roles!(context, subjects)
|
|
23
|
+
else
|
|
24
|
+
context.fetch('roles').each_value do |states|
|
|
25
|
+
raise Error, 'Unregistered action subject' unless (states.keys - subjects).empty?
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
contexts
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.validate_resource_roles!(context, subjects)
|
|
33
|
+
examined = context.fetch('examined_roles')
|
|
34
|
+
context.fetch('resources').each do |subject, entry|
|
|
35
|
+
possible, unknown = entry.values_at('possible', 'unknown')
|
|
36
|
+
unless subjects.include?(subject) && !possible.intersect?(unknown) &&
|
|
37
|
+
((possible + unknown) - examined).empty?
|
|
38
|
+
raise Error, 'Resource role lists conflict with examined scope'
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_class_method :validate_resource_roles!
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Petticoat
|
|
4
|
+
class Snapshot
|
|
5
|
+
attr_reader :adapter
|
|
6
|
+
|
|
7
|
+
def initialize(ability)
|
|
8
|
+
@adapter = Adapter.new(ability)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def state(action, subject)
|
|
12
|
+
return 'unknown' if unsupported?
|
|
13
|
+
|
|
14
|
+
# An object cannot equal an explicit Symbol action, so this queries only
|
|
15
|
+
# manage grants. No permission predicate is evaluated.
|
|
16
|
+
query = action == '*' ? Object.new : action.to_sym
|
|
17
|
+
relevant = adapter.relevant(query, subject)
|
|
18
|
+
return 'unknown' if raw_conditions?(relevant)
|
|
19
|
+
|
|
20
|
+
possibilities = outcomes(relevant)
|
|
21
|
+
return 'conditional' if possibilities.size > 1
|
|
22
|
+
|
|
23
|
+
possibilities.first ? 'unconditional' : 'denied'
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def actions
|
|
27
|
+
adapter.actions.grep(Symbol).map(&:to_s).sort
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def aliases
|
|
31
|
+
adapter.aliases.to_h { |key, values| [key.to_s, values.map(&:to_s).uniq.sort] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def subjects
|
|
35
|
+
adapter.rules.flat_map(&:subjects).uniq
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def evidence(subject_identifier)
|
|
39
|
+
adapter.rules.map do |rule|
|
|
40
|
+
{
|
|
41
|
+
'allow' => rule.base_behavior,
|
|
42
|
+
'actions' => rule.actions.filter_map { |action| action.to_s if action.is_a?(Symbol) },
|
|
43
|
+
'subjects' => rule.subjects.map { |subject| subject_identifier.call(subject) || 'unsupported' },
|
|
44
|
+
'predicate' => predicate_kind(rule),
|
|
45
|
+
'attributes_restricted' => !rule.attributes.empty?
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def raw_conditions?(relevant)
|
|
53
|
+
relevant.any? { |rule| !rule.conditions.is_a?(Hash) && !rule.conditions.nil? }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def outcomes(relevant)
|
|
57
|
+
relevant.reverse_each.reduce([false]) do |values, rule|
|
|
58
|
+
conditional?(rule) ? values | [rule.base_behavior] : [rule.base_behavior]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def conditional?(rule)
|
|
63
|
+
rule.block || !rule.attributes.empty? || (rule.conditions && !rule.conditions.empty?)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unsupported?
|
|
67
|
+
!adapter.issues.empty? || adapter.unsupported_subjects? ||
|
|
68
|
+
adapter.rules.any? { |rule| rule.actions.empty? || rule.actions.any? { |action| !action.is_a?(Symbol) } }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def predicate_kind(rule)
|
|
72
|
+
return 'block' if rule.block
|
|
73
|
+
return 'none' if rule.conditions.nil? || (rule.conditions.is_a?(Hash) && rule.conditions.empty?)
|
|
74
|
+
|
|
75
|
+
rule.conditions.is_a?(Hash) ? 'hash' : 'unsupported'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/petticoat.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'cancan'
|
|
6
|
+
require_relative 'petticoat/version'
|
|
7
|
+
|
|
8
|
+
module Petticoat
|
|
9
|
+
class Error < StandardError; end
|
|
10
|
+
|
|
11
|
+
def self.canonical(value)
|
|
12
|
+
case value
|
|
13
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value.fetch(key))] }
|
|
14
|
+
when Array then value.map { |item| canonical(item) }
|
|
15
|
+
else value
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.canonical_json(value)
|
|
20
|
+
"#{JSON.pretty_generate(canonical(value))}\n"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
require_relative 'petticoat/adapter'
|
|
25
|
+
require_relative 'petticoat/snapshot'
|
|
26
|
+
require_relative 'petticoat/document'
|
|
27
|
+
require_relative 'petticoat/schema'
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Generated by Petticoat. The gem owns the executable; the app owns its integration.
|
|
5
|
+
ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__)
|
|
6
|
+
ENV['BUNDLE_FROZEN'] = 'true'
|
|
7
|
+
require 'bundler/setup'
|
|
8
|
+
load Gem.bin_path('petticoat', 'petticoat')
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Command configuration, not a framework initializer. Only export/check/test load it.
|
|
4
|
+
# Require your application-owned runner here, then replace the error below with:
|
|
5
|
+
# Petticoat::CLI.integration = YourApplication::CapabilityRunner
|
|
6
|
+
#
|
|
7
|
+
# The runner implements call(mode:, arguments:, options:, out:).
|
|
8
|
+
# It owns safe test setup, scenario construction, output paths and publication.
|
|
9
|
+
# It must raise on failure; returning false or an exit code is not sufficient.
|
|
10
|
+
# See the Petticoat README, "Connect your application", for the complete contract.
|
|
11
|
+
raise Petticoat::Error, 'Petticoat: integration is not configured. ' \
|
|
12
|
+
'Edit config/petticoat.rb to select your application runner.'
|
metadata
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: petticoat
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Maurizio De Magnis <root@olisti.co>
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: cancancan
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - '='
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 3.6.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - '='
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 3.6.0
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: json
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.21'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.21'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: json-schema
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - '='
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: 6.2.0
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - '='
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: 6.2.0
|
|
54
|
+
description: Captures completed abilities and conservatively exports deterministic
|
|
55
|
+
role/resource/action catalogues.
|
|
56
|
+
executables:
|
|
57
|
+
- petticoat
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- README.md
|
|
62
|
+
- exe/petticoat
|
|
63
|
+
- lib/petticoat.rb
|
|
64
|
+
- lib/petticoat/adapter.rb
|
|
65
|
+
- lib/petticoat/cli.rb
|
|
66
|
+
- lib/petticoat/document.rb
|
|
67
|
+
- lib/petticoat/installation_path.rb
|
|
68
|
+
- lib/petticoat/installer.rb
|
|
69
|
+
- lib/petticoat/schema.json
|
|
70
|
+
- lib/petticoat/schema.rb
|
|
71
|
+
- lib/petticoat/snapshot.rb
|
|
72
|
+
- lib/petticoat/version.rb
|
|
73
|
+
- templates/bin/petticoat
|
|
74
|
+
- templates/config/petticoat.rb
|
|
75
|
+
homepage: https://source.olisti.co/olisti.co/petticoat
|
|
76
|
+
licenses:
|
|
77
|
+
- Nonstandard
|
|
78
|
+
metadata:
|
|
79
|
+
allowed_push_host: https://rubygems.org
|
|
80
|
+
rubygems_mfa_required: 'true'
|
|
81
|
+
rdoc_options: []
|
|
82
|
+
require_paths:
|
|
83
|
+
- lib
|
|
84
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: 4.0.0
|
|
89
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
90
|
+
requirements:
|
|
91
|
+
- - ">="
|
|
92
|
+
- !ruby/object:Gem::Version
|
|
93
|
+
version: '0'
|
|
94
|
+
requirements: []
|
|
95
|
+
rubygems_version: 4.0.16
|
|
96
|
+
specification_version: 4
|
|
97
|
+
summary: Observed UI capability catalogues from CanCanCan abilities
|
|
98
|
+
test_files: []
|