├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── MIT-LICENSE ├── README.md ├── Rakefile ├── app └── models │ └── granity │ └── relation_tuple.rb ├── bin ├── rails ├── rubocop └── setup ├── config └── routes.rb ├── db └── migrate │ └── 20250317000000_create_granity_relation_tuples.rb ├── granity.gemspec ├── lib ├── generators │ └── granity │ │ └── install │ │ ├── USAGE │ │ ├── install_generator.rb │ │ └── templates │ │ ├── create_granity_tables.rb │ │ └── initializer.rb ├── granity.rb ├── granity │ ├── authorization_engine.rb │ ├── configuration.rb │ ├── dependency_analyzer.rb │ ├── engine.rb │ ├── in_memory_cache.rb │ ├── permission.rb │ ├── permission_evaluator.rb │ ├── relation.rb │ ├── resource_type.rb │ ├── rules.rb │ ├── schema.rb │ └── version.rb └── tasks │ └── granity_tasks.rake └── spec ├── dummy ├── Rakefile ├── app │ ├── assets │ │ └── stylesheets │ │ │ └── application.css │ ├── controllers │ │ └── application_controller.rb │ ├── models │ │ └── application_record.rb │ └── views │ │ ├── layouts │ │ └── application.html.erb │ │ └── pwa │ │ ├── manifest.json.erb │ │ └── service-worker.js ├── bin │ ├── dev │ ├── rails │ ├── rake │ └── setup ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── assets.rb │ │ ├── content_security_policy.rb │ │ ├── filter_parameter_logging.rb │ │ ├── granity.rb │ │ └── inflections.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ └── routes.rb ├── db │ └── schema.rb └── public │ ├── 400.html │ ├── 404.html │ ├── 406-unsupported-browser.html │ ├── 422.html │ ├── 500.html │ ├── icon.png │ └── icon.svg ├── factories └── examples.rb ├── granity ├── authorization_engine_spec.rb ├── permission_evaluator_spec.rb └── schema_spec.rb ├── integration └── scenarios │ ├── drive_authorization_spec.rb │ ├── github_authorization_spec.rb │ └── project_management_authorization_spec.rb ├── rails_helper.rb ├── spec_helper.rb └── support └── factory_bot.rb /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: github-actions 9 | directory: "/" 10 | schedule: 11 | interval: daily 12 | open-pull-requests-limit: 10 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: Set up Ruby 16 | uses: ruby/setup-ruby@v1 17 | with: 18 | ruby-version: ruby-3.4.2 19 | bundler-cache: true 20 | 21 | - name: Lint code for consistent style 22 | run: bin/rubocop -f github 23 | 24 | test: 25 | runs-on: ubuntu-latest 26 | 27 | # services: 28 | # redis: 29 | # image: redis 30 | # ports: 31 | # - 6379:6379 32 | # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 33 | steps: 34 | - name: Install packages 35 | run: sudo apt-get update && sudo apt-get install --no-install-recommends -y build-essential git libyaml-dev pkg-config google-chrome-stable 36 | 37 | - name: Checkout code 38 | uses: actions/checkout@v4 39 | 40 | - name: Set up Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ruby-3.4.2 44 | bundler-cache: true 45 | 46 | - name: Run tests 47 | env: 48 | RAILS_ENV: test 49 | # REDIS_URL: redis://localhost:6379/0 50 | run: bundle exec rspec --force-color 51 | 52 | - name: Keep screenshots from failed system tests 53 | uses: actions/upload-artifact@v4 54 | if: failure() 55 | with: 56 | name: screenshots 57 | path: ${{ github.workspace }}/tmp/screenshots 58 | if-no-files-found: ignore 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /spec/dummy/db/*.sqlite3 7 | /spec/dummy/db/*.sqlite3-* 8 | /spec/dummy/log/*.log 9 | /spec/dummy/storage/ 10 | /spec/dummy/tmp/ 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --format documentation -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Bundler/DuplicatedGem: 2 | Enabled: true 3 | Include: 4 | - "**/*.gemfile" 5 | - "**/Gemfile" 6 | - "**/gems.rb" 7 | 8 | Bundler/DuplicatedGroup: 9 | Enabled: false 10 | 11 | Bundler/GemComment: 12 | Enabled: false 13 | 14 | Bundler/GemFilename: 15 | Enabled: false 16 | 17 | Bundler/GemVersion: 18 | Enabled: false 19 | 20 | Bundler/InsecureProtocolSource: 21 | Enabled: true 22 | Include: 23 | - "**/*.gemfile" 24 | - "**/Gemfile" 25 | - "**/gems.rb" 26 | 27 | Bundler/OrderedGems: 28 | Enabled: false 29 | 30 | Gemspec/AddRuntimeDependency: 31 | Enabled: false 32 | 33 | Gemspec/DependencyVersion: 34 | Enabled: false 35 | 36 | Gemspec/DeprecatedAttributeAssignment: 37 | Enabled: true 38 | 39 | Gemspec/DevelopmentDependencies: 40 | Enabled: false 41 | 42 | Gemspec/DuplicatedAssignment: 43 | Enabled: true 44 | Include: 45 | - "**/*.gemspec" 46 | 47 | Gemspec/OrderedDependencies: 48 | Enabled: false 49 | 50 | Gemspec/RequireMFA: 51 | Enabled: false 52 | 53 | Gemspec/RequiredRubyVersion: 54 | Enabled: false 55 | 56 | Gemspec/RubyVersionGlobalsUsage: 57 | Enabled: false 58 | 59 | Layout/AccessModifierIndentation: 60 | Enabled: true 61 | EnforcedStyle: indent 62 | IndentationWidth: ~ 63 | 64 | Layout/ArgumentAlignment: 65 | Enabled: true 66 | EnforcedStyle: with_fixed_indentation 67 | 68 | Layout/ArrayAlignment: 69 | Enabled: true 70 | EnforcedStyle: with_fixed_indentation 71 | 72 | Layout/AssignmentIndentation: 73 | Enabled: true 74 | IndentationWidth: ~ 75 | 76 | Layout/BeginEndAlignment: 77 | Enabled: true 78 | EnforcedStyleAlignWith: start_of_line 79 | Severity: warning 80 | 81 | Layout/BlockAlignment: 82 | Enabled: true 83 | EnforcedStyleAlignWith: either 84 | 85 | Layout/BlockEndNewline: 86 | Enabled: true 87 | 88 | Layout/CaseIndentation: 89 | Enabled: true 90 | EnforcedStyle: end 91 | 92 | Layout/ClassStructure: 93 | Enabled: false 94 | 95 | Layout/ClosingHeredocIndentation: 96 | Enabled: true 97 | 98 | Layout/ClosingParenthesisIndentation: 99 | Enabled: true 100 | 101 | Layout/CommentIndentation: 102 | Enabled: true 103 | 104 | Layout/ConditionPosition: 105 | Enabled: true 106 | 107 | Layout/DefEndAlignment: 108 | Enabled: true 109 | EnforcedStyleAlignWith: start_of_line 110 | Severity: warning 111 | 112 | Layout/DotPosition: 113 | Enabled: true 114 | EnforcedStyle: leading 115 | 116 | Layout/ElseAlignment: 117 | Enabled: true 118 | 119 | Layout/EmptyComment: 120 | Enabled: true 121 | AllowBorderComment: true 122 | AllowMarginComment: true 123 | 124 | Layout/EmptyLineAfterGuardClause: 125 | Enabled: false 126 | 127 | Layout/EmptyLineAfterMagicComment: 128 | Enabled: true 129 | 130 | Layout/EmptyLineAfterMultilineCondition: 131 | Enabled: false 132 | 133 | Layout/EmptyLineBetweenDefs: 134 | Enabled: true 135 | AllowAdjacentOneLineDefs: false 136 | NumberOfEmptyLines: 1 137 | 138 | Layout/EmptyLines: 139 | Enabled: true 140 | 141 | Layout/EmptyLinesAroundAccessModifier: 142 | Enabled: true 143 | 144 | Layout/EmptyLinesAroundArguments: 145 | Enabled: true 146 | 147 | Layout/EmptyLinesAroundAttributeAccessor: 148 | Enabled: false 149 | 150 | Layout/EmptyLinesAroundBeginBody: 151 | Enabled: true 152 | 153 | Layout/EmptyLinesAroundBlockBody: 154 | Enabled: true 155 | EnforcedStyle: no_empty_lines 156 | 157 | Layout/EmptyLinesAroundClassBody: 158 | Enabled: true 159 | EnforcedStyle: no_empty_lines 160 | 161 | Layout/EmptyLinesAroundExceptionHandlingKeywords: 162 | Enabled: true 163 | 164 | Layout/EmptyLinesAroundMethodBody: 165 | Enabled: true 166 | 167 | Layout/EmptyLinesAroundModuleBody: 168 | Enabled: true 169 | EnforcedStyle: no_empty_lines 170 | 171 | Layout/EndAlignment: 172 | Enabled: true 173 | EnforcedStyleAlignWith: variable 174 | Severity: warning 175 | 176 | Layout/EndOfLine: 177 | Enabled: true 178 | EnforcedStyle: native 179 | 180 | Layout/ExtraSpacing: 181 | Enabled: true 182 | AllowForAlignment: false 183 | AllowBeforeTrailingComments: true 184 | ForceEqualSignAlignment: false 185 | 186 | Layout/FirstArgumentIndentation: 187 | Enabled: true 188 | EnforcedStyle: consistent 189 | IndentationWidth: ~ 190 | 191 | Layout/FirstArrayElementIndentation: 192 | Enabled: true 193 | EnforcedStyle: consistent 194 | IndentationWidth: ~ 195 | 196 | Layout/FirstArrayElementLineBreak: 197 | Enabled: false 198 | 199 | Layout/FirstHashElementIndentation: 200 | Enabled: true 201 | EnforcedStyle: consistent 202 | IndentationWidth: ~ 203 | 204 | Layout/FirstHashElementLineBreak: 205 | Enabled: false 206 | 207 | Layout/FirstMethodArgumentLineBreak: 208 | Enabled: false 209 | 210 | Layout/FirstMethodParameterLineBreak: 211 | Enabled: false 212 | 213 | Layout/FirstParameterIndentation: 214 | Enabled: false 215 | 216 | Layout/HashAlignment: 217 | Enabled: true 218 | EnforcedHashRocketStyle: key 219 | EnforcedColonStyle: key 220 | EnforcedLastArgumentHashStyle: always_inspect 221 | 222 | Layout/HeredocArgumentClosingParenthesis: 223 | Enabled: false 224 | 225 | Layout/HeredocIndentation: 226 | Enabled: true 227 | 228 | Layout/IndentationConsistency: 229 | Enabled: true 230 | EnforcedStyle: normal 231 | 232 | Layout/IndentationStyle: 233 | Enabled: true 234 | IndentationWidth: ~ 235 | 236 | Layout/IndentationWidth: 237 | Enabled: true 238 | Width: 2 239 | AllowedPatterns: [] 240 | 241 | Layout/InitialIndentation: 242 | Enabled: true 243 | 244 | Layout/LeadingCommentSpace: 245 | Enabled: true 246 | 247 | Layout/LeadingEmptyLines: 248 | Enabled: true 249 | 250 | Layout/LineContinuationLeadingSpace: 251 | Enabled: false 252 | 253 | Layout/LineContinuationSpacing: 254 | Enabled: true 255 | 256 | Layout/LineEndStringConcatenationIndentation: 257 | Enabled: false 258 | 259 | Layout/LineLength: 260 | Enabled: false 261 | 262 | Layout/MultilineArrayBraceLayout: 263 | Enabled: true 264 | EnforcedStyle: symmetrical 265 | 266 | Layout/MultilineArrayLineBreaks: 267 | Enabled: false 268 | 269 | Layout/MultilineAssignmentLayout: 270 | Enabled: false 271 | 272 | Layout/MultilineBlockLayout: 273 | Enabled: true 274 | 275 | Layout/MultilineHashBraceLayout: 276 | Enabled: true 277 | EnforcedStyle: symmetrical 278 | 279 | Layout/MultilineHashKeyLineBreaks: 280 | Enabled: false 281 | 282 | Layout/MultilineMethodArgumentLineBreaks: 283 | Enabled: false 284 | 285 | Layout/MultilineMethodCallBraceLayout: 286 | Enabled: true 287 | EnforcedStyle: symmetrical 288 | 289 | Layout/MultilineMethodCallIndentation: 290 | Enabled: true 291 | EnforcedStyle: indented 292 | IndentationWidth: ~ 293 | 294 | Layout/MultilineMethodDefinitionBraceLayout: 295 | Enabled: true 296 | EnforcedStyle: symmetrical 297 | 298 | Layout/MultilineMethodParameterLineBreaks: 299 | Enabled: false 300 | 301 | Layout/MultilineOperationIndentation: 302 | Enabled: true 303 | EnforcedStyle: indented 304 | IndentationWidth: ~ 305 | 306 | Layout/ParameterAlignment: 307 | Enabled: true 308 | EnforcedStyle: with_fixed_indentation 309 | IndentationWidth: ~ 310 | 311 | Layout/RedundantLineBreak: 312 | Enabled: false 313 | 314 | Layout/RescueEnsureAlignment: 315 | Enabled: true 316 | 317 | Layout/SingleLineBlockChain: 318 | Enabled: false 319 | 320 | Layout/SpaceAfterColon: 321 | Enabled: true 322 | 323 | Layout/SpaceAfterComma: 324 | Enabled: true 325 | 326 | Layout/SpaceAfterMethodName: 327 | Enabled: true 328 | 329 | Layout/SpaceAfterNot: 330 | Enabled: true 331 | 332 | Layout/SpaceAfterSemicolon: 333 | Enabled: true 334 | 335 | Layout/SpaceAroundBlockParameters: 336 | Enabled: true 337 | EnforcedStyleInsidePipes: no_space 338 | 339 | Layout/SpaceAroundEqualsInParameterDefault: 340 | Enabled: true 341 | EnforcedStyle: space 342 | 343 | Layout/SpaceAroundKeyword: 344 | Enabled: true 345 | 346 | Layout/SpaceAroundMethodCallOperator: 347 | Enabled: true 348 | 349 | Layout/SpaceAroundOperators: 350 | Enabled: true 351 | AllowForAlignment: true 352 | 353 | Layout/SpaceBeforeBlockBraces: 354 | Enabled: true 355 | EnforcedStyle: space 356 | EnforcedStyleForEmptyBraces: space 357 | 358 | Layout/SpaceBeforeBrackets: 359 | Enabled: false 360 | 361 | Layout/SpaceBeforeComma: 362 | Enabled: true 363 | 364 | Layout/SpaceBeforeComment: 365 | Enabled: true 366 | 367 | Layout/SpaceBeforeFirstArg: 368 | Enabled: true 369 | AllowForAlignment: true 370 | 371 | Layout/SpaceBeforeSemicolon: 372 | Enabled: true 373 | 374 | Layout/SpaceInLambdaLiteral: 375 | Enabled: true 376 | EnforcedStyle: require_no_space 377 | 378 | Layout/SpaceInsideArrayLiteralBrackets: 379 | Enabled: true 380 | EnforcedStyle: no_space 381 | EnforcedStyleForEmptyBrackets: no_space 382 | 383 | Layout/SpaceInsideArrayPercentLiteral: 384 | Enabled: true 385 | 386 | Layout/SpaceInsideBlockBraces: 387 | Enabled: true 388 | EnforcedStyle: space 389 | EnforcedStyleForEmptyBraces: no_space 390 | SpaceBeforeBlockParameters: true 391 | 392 | Layout/SpaceInsideHashLiteralBraces: 393 | Enabled: true 394 | EnforcedStyle: no_space 395 | EnforcedStyleForEmptyBraces: no_space 396 | 397 | Layout/SpaceInsideParens: 398 | Enabled: true 399 | EnforcedStyle: no_space 400 | 401 | Layout/SpaceInsidePercentLiteralDelimiters: 402 | Enabled: true 403 | 404 | Layout/SpaceInsideRangeLiteral: 405 | Enabled: true 406 | 407 | Layout/SpaceInsideReferenceBrackets: 408 | Enabled: true 409 | EnforcedStyle: no_space 410 | EnforcedStyleForEmptyBrackets: no_space 411 | 412 | Layout/SpaceInsideStringInterpolation: 413 | Enabled: true 414 | EnforcedStyle: no_space 415 | 416 | Layout/TrailingEmptyLines: 417 | Enabled: true 418 | EnforcedStyle: final_newline 419 | 420 | Layout/TrailingWhitespace: 421 | Enabled: true 422 | AllowInHeredoc: true 423 | 424 | Lint/AmbiguousAssignment: 425 | Enabled: true 426 | 427 | Lint/AmbiguousBlockAssociation: 428 | Enabled: false 429 | 430 | Lint/AmbiguousOperator: 431 | Enabled: true 432 | 433 | Lint/AmbiguousOperatorPrecedence: 434 | Enabled: false 435 | 436 | Lint/AmbiguousRange: 437 | Enabled: false 438 | 439 | Lint/AmbiguousRegexpLiteral: 440 | Enabled: true 441 | 442 | Lint/ArrayLiteralInRegexp: 443 | Enabled: true 444 | AutoCorrect: false 445 | 446 | Lint/AssignmentInCondition: 447 | Enabled: true 448 | AllowSafeAssignment: true 449 | # Intentionally disable autocorrect to force us to intentionally decide 450 | # whether assignment is intended as opposed to comparison 451 | AutoCorrect: false 452 | 453 | Lint/BigDecimalNew: 454 | Enabled: true 455 | 456 | Lint/BinaryOperatorWithIdenticalOperands: 457 | Enabled: true 458 | 459 | Lint/BooleanSymbol: 460 | Enabled: true 461 | 462 | Lint/CircularArgumentReference: 463 | Enabled: true 464 | 465 | Lint/ConstantDefinitionInBlock: 466 | Enabled: true 467 | 468 | Lint/ConstantOverwrittenInRescue: 469 | Enabled: true 470 | 471 | Lint/ConstantReassignment: 472 | Enabled: true 473 | 474 | Lint/ConstantResolution: 475 | Enabled: false 476 | 477 | Lint/CopDirectiveSyntax: 478 | Enabled: true 479 | 480 | Lint/Debugger: 481 | Enabled: true 482 | 483 | Lint/DeprecatedClassMethods: 484 | Enabled: true 485 | 486 | Lint/DeprecatedConstants: 487 | Enabled: true 488 | 489 | Lint/DeprecatedOpenSSLConstant: 490 | Enabled: true 491 | 492 | Lint/DisjunctiveAssignmentInConstructor: 493 | Enabled: false 494 | 495 | Lint/DuplicateBranch: 496 | Enabled: false 497 | 498 | Lint/DuplicateCaseCondition: 499 | Enabled: true 500 | 501 | Lint/DuplicateElsifCondition: 502 | Enabled: true 503 | 504 | Lint/DuplicateHashKey: 505 | Enabled: true 506 | 507 | Lint/DuplicateMagicComment: 508 | Enabled: true 509 | 510 | Lint/DuplicateMatchPattern: 511 | Enabled: false 512 | 513 | Lint/DuplicateMethods: 514 | Enabled: true 515 | 516 | Lint/DuplicateRegexpCharacterClassElement: 517 | Enabled: true 518 | 519 | Lint/DuplicateRequire: 520 | Enabled: true 521 | 522 | Lint/DuplicateRescueException: 523 | Enabled: true 524 | 525 | Lint/DuplicateSetElement: 526 | Enabled: false 527 | 528 | Lint/EachWithObjectArgument: 529 | Enabled: true 530 | 531 | Lint/ElseLayout: 532 | Enabled: true 533 | 534 | Lint/EmptyBlock: 535 | Enabled: false 536 | 537 | Lint/EmptyClass: 538 | Enabled: false 539 | 540 | Lint/EmptyConditionalBody: 541 | Enabled: false 542 | 543 | Lint/EmptyEnsure: 544 | Enabled: true 545 | 546 | Lint/EmptyExpression: 547 | Enabled: true 548 | 549 | Lint/EmptyFile: 550 | Enabled: false 551 | 552 | Lint/EmptyInPattern: 553 | Enabled: false 554 | 555 | Lint/EmptyInterpolation: 556 | Enabled: true 557 | 558 | Lint/EmptyWhen: 559 | Enabled: true 560 | AllowComments: true 561 | 562 | Lint/EnsureReturn: 563 | Enabled: true 564 | 565 | Lint/ErbNewArguments: 566 | Enabled: true 567 | 568 | Lint/FlipFlop: 569 | Enabled: true 570 | 571 | Lint/FloatComparison: 572 | Enabled: true 573 | 574 | Lint/FloatOutOfRange: 575 | Enabled: true 576 | 577 | Lint/FormatParameterMismatch: 578 | Enabled: true 579 | 580 | Lint/HashCompareByIdentity: 581 | Enabled: false 582 | 583 | Lint/HashNewWithKeywordArgumentsAsDefault: 584 | Enabled: true 585 | 586 | Lint/HeredocMethodCallPosition: 587 | Enabled: false 588 | 589 | Lint/IdentityComparison: 590 | Enabled: true 591 | 592 | Lint/ImplicitStringConcatenation: 593 | Enabled: true 594 | 595 | Lint/IncompatibleIoSelectWithFiberScheduler: 596 | Enabled: false 597 | 598 | Lint/IneffectiveAccessModifier: 599 | Enabled: true 600 | 601 | Lint/InheritException: 602 | Enabled: true 603 | EnforcedStyle: runtime_error 604 | 605 | Lint/InterpolationCheck: 606 | Enabled: true 607 | 608 | Lint/ItWithoutArgumentsInBlock: 609 | Enabled: true 610 | 611 | Lint/LambdaWithoutLiteralBlock: 612 | Enabled: false 613 | 614 | Lint/LiteralAsCondition: 615 | Enabled: true 616 | 617 | Lint/LiteralAssignmentInCondition: 618 | Enabled: true 619 | 620 | Lint/LiteralInInterpolation: 621 | Enabled: true 622 | 623 | Lint/Loop: 624 | Enabled: true 625 | 626 | Lint/MissingCopEnableDirective: 627 | Enabled: true 628 | MaximumRangeSize: .inf 629 | 630 | Lint/MissingSuper: 631 | Enabled: false 632 | 633 | Lint/MixedCaseRange: 634 | Enabled: true 635 | 636 | Lint/MixedRegexpCaptureTypes: 637 | Enabled: true 638 | 639 | Lint/MultipleComparison: 640 | Enabled: true 641 | 642 | Lint/NestedMethodDefinition: 643 | Enabled: true 644 | 645 | Lint/NestedPercentLiteral: 646 | Enabled: true 647 | 648 | Lint/NextWithoutAccumulator: 649 | Enabled: true 650 | 651 | Lint/NoReturnInBeginEndBlocks: 652 | Enabled: false 653 | 654 | Lint/NonAtomicFileOperation: 655 | Enabled: false 656 | 657 | Lint/NonDeterministicRequireOrder: 658 | Enabled: true 659 | 660 | Lint/NonLocalExitFromIterator: 661 | Enabled: true 662 | 663 | Lint/NumberConversion: 664 | Enabled: false 665 | 666 | Lint/NumberedParameterAssignment: 667 | Enabled: true 668 | 669 | Lint/NumericOperationWithConstantResult: 670 | Enabled: false 671 | 672 | Lint/OrAssignmentToConstant: 673 | Enabled: true 674 | 675 | Lint/OrderedMagicComments: 676 | Enabled: true 677 | 678 | Lint/OutOfRangeRegexpRef: 679 | Enabled: true 680 | 681 | Lint/ParenthesesAsGroupedExpression: 682 | Enabled: true 683 | 684 | Lint/PercentStringArray: 685 | Enabled: false 686 | 687 | Lint/PercentSymbolArray: 688 | Enabled: true 689 | 690 | Lint/RaiseException: 691 | Enabled: true 692 | 693 | Lint/RandOne: 694 | Enabled: true 695 | 696 | Lint/RedundantCopDisableDirective: 697 | Enabled: false 698 | 699 | Lint/RedundantCopEnableDirective: 700 | Enabled: false 701 | 702 | Lint/RedundantDirGlobSort: 703 | Enabled: false 704 | 705 | Lint/RedundantRegexpQuantifiers: 706 | Enabled: true 707 | 708 | Lint/RedundantRequireStatement: 709 | Enabled: true 710 | 711 | Lint/RedundantSafeNavigation: 712 | Enabled: false 713 | 714 | Lint/RedundantSplatExpansion: 715 | Enabled: true 716 | 717 | Lint/RedundantStringCoercion: 718 | Enabled: true 719 | 720 | Lint/RedundantTypeConversion: 721 | Enabled: true 722 | 723 | Lint/RedundantWithIndex: 724 | Enabled: true 725 | 726 | Lint/RedundantWithObject: 727 | Enabled: true 728 | 729 | Lint/RefinementImportMethods: 730 | Enabled: true 731 | 732 | Lint/RegexpAsCondition: 733 | Enabled: true 734 | 735 | Lint/RequireParentheses: 736 | Enabled: true 737 | 738 | Lint/RequireRangeParentheses: 739 | Enabled: true 740 | 741 | Lint/RequireRelativeSelfPath: 742 | Enabled: true 743 | 744 | Lint/RescueException: 745 | Enabled: true 746 | 747 | Lint/RescueType: 748 | Enabled: true 749 | 750 | Lint/ReturnInVoidContext: 751 | Enabled: true 752 | 753 | Lint/SafeNavigationChain: 754 | Enabled: true 755 | AllowedMethods: 756 | - present? 757 | - blank? 758 | - presence 759 | - try 760 | - try! 761 | 762 | Lint/SafeNavigationConsistency: 763 | Enabled: true 764 | AllowedMethods: 765 | - present? 766 | - blank? 767 | - presence 768 | - try 769 | - try! 770 | 771 | Lint/SafeNavigationWithEmpty: 772 | Enabled: true 773 | 774 | Lint/ScriptPermission: 775 | Enabled: false 776 | 777 | Lint/SelfAssignment: 778 | Enabled: true 779 | 780 | Lint/SendWithMixinArgument: 781 | Enabled: false 782 | 783 | Lint/ShadowedArgument: 784 | Enabled: true 785 | IgnoreImplicitReferences: false 786 | 787 | Lint/ShadowedException: 788 | Enabled: true 789 | 790 | Lint/ShadowingOuterLocalVariable: 791 | Enabled: false 792 | 793 | Lint/SharedMutableDefault: 794 | Enabled: true 795 | 796 | Lint/StructNewOverride: 797 | Enabled: false 798 | 799 | Lint/SuppressedException: 800 | Enabled: false 801 | 802 | Lint/SuppressedExceptionInNumberConversion: 803 | Enabled: true 804 | AutoCorrect: false 805 | 806 | Lint/SymbolConversion: 807 | Enabled: true 808 | 809 | Lint/Syntax: 810 | Enabled: true 811 | 812 | Lint/ToEnumArguments: 813 | Enabled: false 814 | 815 | Lint/ToJSON: 816 | Enabled: false 817 | 818 | Lint/TopLevelReturnWithArgument: 819 | Enabled: true 820 | 821 | Lint/TrailingCommaInAttributeDeclaration: 822 | Enabled: true 823 | 824 | Lint/TripleQuotes: 825 | Enabled: true 826 | 827 | Lint/UnderscorePrefixedVariableName: 828 | Enabled: true 829 | 830 | Lint/UnescapedBracketInRegexp: 831 | Enabled: false 832 | 833 | Lint/UnexpectedBlockArity: 834 | Enabled: false 835 | 836 | Lint/UnifiedInteger: 837 | Enabled: true 838 | 839 | Lint/UnmodifiedReduceAccumulator: 840 | Enabled: false 841 | 842 | Lint/UnreachableCode: 843 | Enabled: true 844 | 845 | Lint/UnreachableLoop: 846 | Enabled: false 847 | 848 | Lint/UnusedBlockArgument: 849 | Enabled: false 850 | 851 | Lint/UnusedMethodArgument: 852 | Enabled: false 853 | 854 | Lint/UriEscapeUnescape: 855 | Enabled: true 856 | 857 | Lint/UriRegexp: 858 | Enabled: true 859 | 860 | Lint/UselessAccessModifier: 861 | Enabled: false 862 | 863 | Lint/UselessAssignment: 864 | Enabled: true 865 | 866 | Lint/UselessConstantScoping: 867 | Enabled: true 868 | 869 | Lint/UselessDefined: 870 | Enabled: true 871 | 872 | Lint/UselessElseWithoutRescue: 873 | Enabled: false 874 | 875 | Lint/UselessMethodDefinition: 876 | Enabled: false 877 | 878 | Lint/UselessNumericOperation: 879 | Enabled: false 880 | 881 | Lint/UselessRescue: 882 | Enabled: true 883 | 884 | Lint/UselessRuby2Keywords: 885 | Enabled: true 886 | 887 | Lint/UselessSetterCall: 888 | Enabled: true 889 | 890 | Lint/UselessTimes: 891 | Enabled: true 892 | 893 | Lint/Void: 894 | Enabled: true 895 | CheckForMethodsWithNoSideEffects: false 896 | 897 | Metrics/AbcSize: 898 | Enabled: false 899 | 900 | Metrics/BlockLength: 901 | Enabled: false 902 | 903 | Metrics/BlockNesting: 904 | Enabled: false 905 | 906 | Metrics/ClassLength: 907 | Enabled: false 908 | 909 | Metrics/CollectionLiteralLength: 910 | Enabled: false 911 | 912 | Metrics/CyclomaticComplexity: 913 | Enabled: false 914 | 915 | Metrics/MethodLength: 916 | Enabled: false 917 | 918 | Metrics/ModuleLength: 919 | Enabled: false 920 | 921 | Metrics/ParameterLists: 922 | Enabled: false 923 | 924 | Metrics/PerceivedComplexity: 925 | Enabled: false 926 | 927 | Migration/DepartmentName: 928 | Enabled: true 929 | 930 | Naming/AccessorMethodName: 931 | Enabled: false 932 | 933 | Naming/AsciiIdentifiers: 934 | Enabled: false 935 | 936 | Naming/BinaryOperatorParameterName: 937 | Enabled: true 938 | 939 | Naming/BlockForwarding: 940 | Enabled: false 941 | 942 | Naming/BlockParameterName: 943 | Enabled: true 944 | MinNameLength: 1 945 | AllowNamesEndingInNumbers: true 946 | AllowedNames: [] 947 | ForbiddenNames: [] 948 | 949 | Naming/ClassAndModuleCamelCase: 950 | Enabled: true 951 | 952 | Naming/ConstantName: 953 | Enabled: true 954 | 955 | Naming/FileName: 956 | Enabled: false 957 | 958 | Naming/HeredocDelimiterCase: 959 | Enabled: true 960 | EnforcedStyle: uppercase 961 | 962 | Naming/HeredocDelimiterNaming: 963 | Enabled: false 964 | 965 | Naming/InclusiveLanguage: 966 | Enabled: false 967 | 968 | Naming/MemoizedInstanceVariableName: 969 | Enabled: false 970 | 971 | Naming/MethodName: 972 | Enabled: false 973 | 974 | Naming/MethodParameterName: 975 | Enabled: false 976 | 977 | Naming/PredicateName: 978 | Enabled: false 979 | 980 | Naming/RescuedExceptionsVariableName: 981 | Enabled: false 982 | 983 | Naming/VariableName: 984 | Enabled: true 985 | EnforcedStyle: snake_case 986 | 987 | Naming/VariableNumber: 988 | Enabled: false 989 | 990 | Security/CompoundHash: 991 | Enabled: true 992 | 993 | Security/Eval: 994 | Enabled: true 995 | 996 | Security/IoMethods: 997 | Enabled: false 998 | 999 | Security/JSONLoad: 1000 | Enabled: true 1001 | 1002 | Security/MarshalLoad: 1003 | Enabled: false 1004 | 1005 | Security/Open: 1006 | Enabled: true 1007 | 1008 | Security/YAMLLoad: 1009 | Enabled: true 1010 | 1011 | Style/AccessModifierDeclarations: 1012 | Enabled: false 1013 | 1014 | Style/AccessorGrouping: 1015 | Enabled: false 1016 | 1017 | Style/Alias: 1018 | Enabled: true 1019 | EnforcedStyle: prefer_alias_method 1020 | 1021 | Style/AmbiguousEndlessMethodDefinition: 1022 | Enabled: false 1023 | 1024 | Style/AndOr: 1025 | Enabled: true 1026 | 1027 | Style/ArgumentsForwarding: 1028 | Enabled: false 1029 | 1030 | Style/ArrayCoercion: 1031 | Enabled: false 1032 | 1033 | Style/ArrayFirstLast: 1034 | Enabled: false 1035 | 1036 | Style/ArrayIntersect: 1037 | Enabled: false 1038 | 1039 | Style/ArrayJoin: 1040 | Enabled: true 1041 | 1042 | Style/AsciiComments: 1043 | Enabled: false 1044 | 1045 | Style/Attr: 1046 | Enabled: true 1047 | 1048 | Style/AutoResourceCleanup: 1049 | Enabled: false 1050 | 1051 | Style/BarePercentLiterals: 1052 | Enabled: true 1053 | EnforcedStyle: bare_percent 1054 | 1055 | Style/BeginBlock: 1056 | Enabled: true 1057 | 1058 | Style/BisectedAttrAccessor: 1059 | Enabled: false 1060 | 1061 | Style/BitwisePredicate: 1062 | Enabled: false 1063 | 1064 | Style/BlockComments: 1065 | Enabled: true 1066 | 1067 | Style/BlockDelimiters: 1068 | Enabled: false 1069 | 1070 | Style/CaseEquality: 1071 | Enabled: false 1072 | 1073 | Style/CaseLikeIf: 1074 | Enabled: false 1075 | 1076 | Style/CharacterLiteral: 1077 | Enabled: true 1078 | 1079 | Style/ClassAndModuleChildren: 1080 | Enabled: false 1081 | 1082 | Style/ClassCheck: 1083 | Enabled: true 1084 | EnforcedStyle: is_a? 1085 | 1086 | Style/ClassEqualityComparison: 1087 | Enabled: true 1088 | 1089 | Style/ClassMethods: 1090 | Enabled: true 1091 | 1092 | Style/ClassMethodsDefinitions: 1093 | Enabled: false 1094 | 1095 | Style/ClassVars: 1096 | Enabled: false 1097 | 1098 | Style/CollectionCompact: 1099 | Enabled: false 1100 | 1101 | Style/CollectionMethods: 1102 | Enabled: false 1103 | 1104 | Style/ColonMethodCall: 1105 | Enabled: true 1106 | 1107 | Style/ColonMethodDefinition: 1108 | Enabled: true 1109 | 1110 | Style/CombinableDefined: 1111 | Enabled: false 1112 | 1113 | Style/CombinableLoops: 1114 | Enabled: false 1115 | 1116 | Style/CommandLiteral: 1117 | Enabled: true 1118 | EnforcedStyle: mixed 1119 | AllowInnerBackticks: false 1120 | 1121 | Style/CommentAnnotation: 1122 | Enabled: false 1123 | 1124 | Style/CommentedKeyword: 1125 | Enabled: false 1126 | 1127 | Style/ComparableClamp: 1128 | Enabled: true 1129 | 1130 | Style/ConcatArrayLiterals: 1131 | Enabled: false 1132 | 1133 | Style/ConditionalAssignment: 1134 | Enabled: true 1135 | EnforcedStyle: assign_to_condition 1136 | SingleLineConditionsOnly: true 1137 | IncludeTernaryExpressions: true 1138 | 1139 | Style/ConstantVisibility: 1140 | Enabled: false 1141 | 1142 | Style/Copyright: 1143 | Enabled: false 1144 | 1145 | Style/DataInheritance: 1146 | Enabled: false 1147 | 1148 | Style/DateTime: 1149 | Enabled: false 1150 | 1151 | Style/DefWithParentheses: 1152 | Enabled: true 1153 | 1154 | Style/DigChain: 1155 | Enabled: true 1156 | 1157 | Style/Dir: 1158 | Enabled: true 1159 | 1160 | Style/DirEmpty: 1161 | Enabled: true 1162 | 1163 | Style/DisableCopsWithinSourceCodeDirective: 1164 | Enabled: false 1165 | 1166 | Style/DocumentDynamicEvalDefinition: 1167 | Enabled: false 1168 | 1169 | Style/Documentation: 1170 | Enabled: false 1171 | 1172 | Style/DocumentationMethod: 1173 | Enabled: false 1174 | 1175 | Style/DoubleCopDisableDirective: 1176 | Enabled: false 1177 | 1178 | Style/DoubleNegation: 1179 | Enabled: false 1180 | 1181 | Style/EachForSimpleLoop: 1182 | Enabled: true 1183 | 1184 | Style/EachWithObject: 1185 | Enabled: true 1186 | 1187 | Style/EmptyBlockParameter: 1188 | Enabled: true 1189 | 1190 | Style/EmptyCaseCondition: 1191 | Enabled: true 1192 | 1193 | Style/EmptyElse: 1194 | Enabled: true 1195 | AllowComments: true 1196 | EnforcedStyle: both 1197 | 1198 | Style/EmptyHeredoc: 1199 | Enabled: false 1200 | 1201 | Style/EmptyLambdaParameter: 1202 | Enabled: true 1203 | 1204 | Style/EmptyLiteral: 1205 | Enabled: true 1206 | 1207 | Style/EmptyMethod: 1208 | Enabled: true 1209 | EnforcedStyle: expanded 1210 | 1211 | Style/Encoding: 1212 | Enabled: true 1213 | 1214 | Style/EndBlock: 1215 | Enabled: true 1216 | 1217 | Style/EndlessMethod: 1218 | Enabled: false 1219 | 1220 | Style/EnvHome: 1221 | Enabled: false 1222 | 1223 | Style/EvalWithLocation: 1224 | Enabled: true 1225 | 1226 | Style/EvenOdd: 1227 | Enabled: false 1228 | 1229 | Style/ExactRegexpMatch: 1230 | Enabled: true 1231 | Style/ExpandPathArguments: 1232 | Enabled: false 1233 | 1234 | Style/ExplicitBlockArgument: 1235 | Enabled: false 1236 | 1237 | Style/ExponentialNotation: 1238 | Enabled: false 1239 | 1240 | Style/FetchEnvVar: 1241 | Enabled: false 1242 | 1243 | Style/FileEmpty: 1244 | Enabled: false 1245 | 1246 | Style/FileNull: 1247 | Enabled: true 1248 | 1249 | Style/FileRead: 1250 | Enabled: true 1251 | 1252 | Style/FileTouch: 1253 | Enabled: false 1254 | 1255 | Style/FileWrite: 1256 | Enabled: true 1257 | 1258 | Style/FloatDivision: 1259 | Enabled: false 1260 | 1261 | Style/For: 1262 | Enabled: true 1263 | EnforcedStyle: each 1264 | 1265 | Style/FormatString: 1266 | Enabled: false 1267 | 1268 | Style/FormatStringToken: 1269 | Enabled: false 1270 | 1271 | Style/FrozenStringLiteralComment: 1272 | Enabled: false 1273 | 1274 | Style/GlobalStdStream: 1275 | Enabled: true 1276 | 1277 | Style/GlobalVars: 1278 | Enabled: true 1279 | AllowedVariables: [] 1280 | 1281 | Style/GuardClause: 1282 | Enabled: false 1283 | 1284 | Style/HashAsLastArrayItem: 1285 | Enabled: false 1286 | 1287 | Style/HashConversion: 1288 | Enabled: true 1289 | 1290 | Style/HashEachMethods: 1291 | Enabled: false 1292 | 1293 | Style/HashExcept: 1294 | Enabled: true 1295 | 1296 | Style/HashLikeCase: 1297 | Enabled: false 1298 | 1299 | Style/HashSlice: 1300 | Enabled: true 1301 | 1302 | Style/HashSyntax: 1303 | Enabled: true 1304 | EnforcedStyle: ruby19_no_mixed_keys 1305 | EnforcedShorthandSyntax: either 1306 | 1307 | Style/HashTransformKeys: 1308 | Enabled: false 1309 | 1310 | Style/HashTransformValues: 1311 | Enabled: false 1312 | 1313 | Style/IdenticalConditionalBranches: 1314 | Enabled: true 1315 | 1316 | Style/IfInsideElse: 1317 | Enabled: true 1318 | 1319 | Style/IfUnlessModifier: 1320 | Enabled: false 1321 | 1322 | Style/IfUnlessModifierOfIfUnless: 1323 | Enabled: true 1324 | 1325 | Style/IfWithBooleanLiteralBranches: 1326 | Enabled: true 1327 | 1328 | Style/IfWithSemicolon: 1329 | Enabled: true 1330 | 1331 | Style/ImplicitRuntimeError: 1332 | Enabled: false 1333 | 1334 | Style/InPatternThen: 1335 | Enabled: false 1336 | 1337 | Style/InfiniteLoop: 1338 | Enabled: true 1339 | 1340 | Style/InlineComment: 1341 | Enabled: false 1342 | 1343 | Style/InverseMethods: 1344 | Enabled: false 1345 | 1346 | Style/InvertibleUnlessCondition: 1347 | Enabled: false 1348 | 1349 | Style/IpAddresses: 1350 | Enabled: false 1351 | 1352 | Style/ItAssignment: 1353 | Enabled: true 1354 | 1355 | Style/KeywordArgumentsMerging: 1356 | Enabled: false 1357 | 1358 | Style/KeywordParametersOrder: 1359 | Enabled: true 1360 | 1361 | Style/Lambda: 1362 | Enabled: false 1363 | 1364 | Style/LambdaCall: 1365 | Enabled: true 1366 | EnforcedStyle: call 1367 | 1368 | Style/LineEndConcatenation: 1369 | Enabled: true 1370 | 1371 | Style/MagicCommentFormat: 1372 | Enabled: false 1373 | 1374 | Style/MapCompactWithConditionalBlock: 1375 | Enabled: true 1376 | 1377 | Style/MapIntoArray: 1378 | Enabled: false 1379 | 1380 | Style/MapToHash: 1381 | Enabled: false 1382 | 1383 | Style/MapToSet: 1384 | Enabled: false 1385 | 1386 | Style/MethodCallWithArgsParentheses: 1387 | Enabled: false 1388 | 1389 | Style/MethodCallWithoutArgsParentheses: 1390 | Enabled: true 1391 | AllowedMethods: [] 1392 | 1393 | Style/MethodCalledOnDoEndBlock: 1394 | Enabled: false 1395 | 1396 | Style/MethodDefParentheses: 1397 | Enabled: false 1398 | 1399 | Style/MinMax: 1400 | Enabled: false 1401 | 1402 | Style/MinMaxComparison: 1403 | Enabled: false 1404 | 1405 | Style/MissingElse: 1406 | Enabled: false 1407 | 1408 | Style/MissingRespondToMissing: 1409 | Enabled: true 1410 | 1411 | Style/MixinGrouping: 1412 | Enabled: true 1413 | EnforcedStyle: separated 1414 | 1415 | Style/MixinUsage: 1416 | Enabled: true 1417 | 1418 | Style/ModuleFunction: 1419 | Enabled: false 1420 | 1421 | Style/MultilineBlockChain: 1422 | Enabled: false 1423 | 1424 | Style/MultilineIfModifier: 1425 | Enabled: true 1426 | 1427 | Style/MultilineIfThen: 1428 | Enabled: true 1429 | 1430 | Style/MultilineInPatternThen: 1431 | Enabled: false 1432 | 1433 | Style/MultilineMemoization: 1434 | Enabled: true 1435 | EnforcedStyle: keyword 1436 | 1437 | Style/MultilineMethodSignature: 1438 | Enabled: false 1439 | 1440 | Style/MultilineTernaryOperator: 1441 | Enabled: false 1442 | 1443 | Style/MultilineWhenThen: 1444 | Enabled: true 1445 | 1446 | Style/MultipleComparison: 1447 | Enabled: false 1448 | 1449 | Style/MutableConstant: 1450 | Enabled: false 1451 | 1452 | Style/NegatedIf: 1453 | Enabled: false 1454 | 1455 | Style/NegatedIfElseCondition: 1456 | Enabled: false 1457 | 1458 | Style/NegatedUnless: 1459 | Enabled: false 1460 | 1461 | Style/NegatedWhile: 1462 | Enabled: true 1463 | 1464 | Style/NestedFileDirname: 1465 | Enabled: true 1466 | 1467 | Style/NestedModifier: 1468 | Enabled: true 1469 | 1470 | Style/NestedParenthesizedCalls: 1471 | Enabled: true 1472 | AllowedMethods: 1473 | - be 1474 | - be_a 1475 | - be_an 1476 | - be_between 1477 | - be_falsey 1478 | - be_kind_of 1479 | - be_instance_of 1480 | - be_truthy 1481 | - be_within 1482 | - eq 1483 | - eql 1484 | - end_with 1485 | - include 1486 | - match 1487 | - raise_error 1488 | - respond_to 1489 | - start_with 1490 | 1491 | Style/NestedTernaryOperator: 1492 | Enabled: true 1493 | 1494 | Style/Next: 1495 | Enabled: false 1496 | 1497 | Style/NilComparison: 1498 | Enabled: true 1499 | EnforcedStyle: predicate 1500 | 1501 | Style/NilLambda: 1502 | Enabled: true 1503 | 1504 | Style/NonNilCheck: 1505 | Enabled: true 1506 | IncludeSemanticChanges: false 1507 | 1508 | Style/Not: 1509 | Enabled: true 1510 | 1511 | Style/NumberedParameters: 1512 | Enabled: false 1513 | 1514 | Style/NumberedParametersLimit: 1515 | Enabled: false 1516 | 1517 | Style/NumericLiteralPrefix: 1518 | Enabled: true 1519 | EnforcedOctalStyle: zero_with_o 1520 | 1521 | Style/NumericLiterals: 1522 | Enabled: false 1523 | 1524 | Style/NumericPredicate: 1525 | Enabled: false 1526 | 1527 | Style/ObjectThen: 1528 | Enabled: false 1529 | 1530 | Style/OneLineConditional: 1531 | Enabled: true 1532 | 1533 | Style/OpenStructUse: 1534 | Enabled: false 1535 | 1536 | Style/OperatorMethodCall: 1537 | Enabled: false 1538 | 1539 | Style/OptionHash: 1540 | Enabled: false 1541 | 1542 | Style/OptionalArguments: 1543 | Enabled: true 1544 | 1545 | Style/OptionalBooleanParameter: 1546 | Enabled: false 1547 | 1548 | Style/OrAssignment: 1549 | Enabled: true 1550 | 1551 | Style/ParallelAssignment: 1552 | Enabled: false 1553 | 1554 | Style/ParenthesesAroundCondition: 1555 | Enabled: true 1556 | AllowSafeAssignment: true 1557 | AllowInMultilineConditions: false 1558 | 1559 | Style/PercentLiteralDelimiters: 1560 | Enabled: true 1561 | PreferredDelimiters: 1562 | default: () 1563 | "%i": "[]" 1564 | "%I": "[]" 1565 | "%r": "{}" 1566 | "%w": "[]" 1567 | "%W": "[]" 1568 | 1569 | Style/PercentQLiterals: 1570 | Enabled: false 1571 | 1572 | Style/PerlBackrefs: 1573 | Enabled: false 1574 | 1575 | Style/PreferredHashMethods: 1576 | Enabled: false 1577 | 1578 | Style/Proc: 1579 | Enabled: true 1580 | 1581 | Style/QuotedSymbols: 1582 | Enabled: true 1583 | EnforcedStyle: same_as_string_literals 1584 | 1585 | Style/RaiseArgs: 1586 | Enabled: false 1587 | 1588 | Style/RandomWithOffset: 1589 | Enabled: true 1590 | 1591 | Style/RedundantArgument: 1592 | Enabled: false 1593 | 1594 | Style/RedundantArrayConstructor: 1595 | Enabled: true 1596 | 1597 | Style/RedundantAssignment: 1598 | Enabled: true 1599 | 1600 | Style/RedundantBegin: 1601 | Enabled: true 1602 | 1603 | Style/RedundantCapitalW: 1604 | Enabled: false 1605 | 1606 | Style/RedundantCondition: 1607 | Enabled: true 1608 | 1609 | Style/RedundantConditional: 1610 | Enabled: true 1611 | 1612 | Style/RedundantConstantBase: 1613 | Enabled: false 1614 | 1615 | Style/RedundantCurrentDirectoryInPath: 1616 | Enabled: true 1617 | 1618 | Style/RedundantDoubleSplatHashBraces: 1619 | Enabled: true 1620 | 1621 | Style/RedundantEach: 1622 | Enabled: false 1623 | 1624 | Style/RedundantException: 1625 | Enabled: true 1626 | 1627 | Style/RedundantFetchBlock: 1628 | Enabled: false 1629 | 1630 | Style/RedundantFileExtensionInRequire: 1631 | Enabled: true 1632 | 1633 | Style/RedundantFilterChain: 1634 | Enabled: false 1635 | 1636 | Style/RedundantFormat: 1637 | Enabled: true 1638 | 1639 | Style/RedundantFreeze: 1640 | Enabled: true 1641 | 1642 | Style/RedundantHeredocDelimiterQuotes: 1643 | Enabled: true 1644 | 1645 | Style/RedundantInitialize: 1646 | Enabled: false 1647 | 1648 | Style/RedundantInterpolation: 1649 | Enabled: true 1650 | 1651 | Style/RedundantInterpolationUnfreeze: 1652 | Enabled: true 1653 | 1654 | Style/RedundantLineContinuation: 1655 | Enabled: true 1656 | 1657 | Style/RedundantParentheses: 1658 | Enabled: true 1659 | 1660 | Style/RedundantPercentQ: 1661 | Enabled: true 1662 | 1663 | Style/RedundantRegexpArgument: 1664 | Enabled: true 1665 | 1666 | Style/RedundantRegexpCharacterClass: 1667 | Enabled: true 1668 | 1669 | Style/RedundantRegexpConstructor: 1670 | Enabled: true 1671 | 1672 | Style/RedundantRegexpEscape: 1673 | Enabled: true 1674 | 1675 | Style/RedundantReturn: 1676 | Enabled: true 1677 | AllowMultipleReturnValues: false 1678 | 1679 | Style/RedundantSelf: 1680 | Enabled: true 1681 | 1682 | Style/RedundantSelfAssignment: 1683 | Enabled: false 1684 | 1685 | Style/RedundantSelfAssignmentBranch: 1686 | Enabled: false 1687 | 1688 | Style/RedundantSort: 1689 | Enabled: true 1690 | 1691 | Style/RedundantSortBy: 1692 | Enabled: true 1693 | 1694 | Style/RedundantStringEscape: 1695 | Enabled: true 1696 | 1697 | Style/RegexpLiteral: 1698 | Enabled: false 1699 | 1700 | Style/RequireOrder: 1701 | Enabled: false 1702 | 1703 | Style/RescueModifier: 1704 | Enabled: true 1705 | 1706 | Style/RescueStandardError: 1707 | Enabled: true 1708 | EnforcedStyle: implicit 1709 | 1710 | Style/ReturnNil: 1711 | Enabled: false 1712 | 1713 | Style/ReturnNilInPredicateMethodDefinition: 1714 | Enabled: false 1715 | 1716 | Style/SafeNavigation: 1717 | Enabled: true 1718 | ConvertCodeThatCanStartToReturnNil: false 1719 | AllowedMethods: 1720 | - present? 1721 | - blank? 1722 | - presence 1723 | - try 1724 | - try! 1725 | 1726 | Style/SafeNavigationChainLength: 1727 | Enabled: false 1728 | 1729 | Style/Sample: 1730 | Enabled: true 1731 | 1732 | Style/SelectByRegexp: 1733 | Enabled: false 1734 | 1735 | Style/SelfAssignment: 1736 | Enabled: true 1737 | 1738 | Style/Semicolon: 1739 | Enabled: true 1740 | AllowAsExpressionSeparator: false 1741 | 1742 | Style/Send: 1743 | Enabled: false 1744 | 1745 | Style/SendWithLiteralMethodName: 1746 | Enabled: false 1747 | 1748 | Style/SignalException: 1749 | Enabled: false 1750 | 1751 | Style/SingleArgumentDig: 1752 | Enabled: false 1753 | 1754 | Style/SingleLineBlockParams: 1755 | Enabled: false 1756 | 1757 | Style/SingleLineDoEndBlock: 1758 | Enabled: false 1759 | 1760 | Style/SingleLineMethods: 1761 | Enabled: true 1762 | AllowIfMethodIsEmpty: false 1763 | 1764 | Style/SlicingWithRange: 1765 | Enabled: true 1766 | 1767 | Style/SoleNestedConditional: 1768 | Enabled: false 1769 | 1770 | Style/SpecialGlobalVars: 1771 | Enabled: false 1772 | 1773 | Style/StabbyLambdaParentheses: 1774 | Enabled: true 1775 | EnforcedStyle: require_parentheses 1776 | 1777 | Style/StaticClass: 1778 | Enabled: false 1779 | 1780 | Style/StderrPuts: 1781 | Enabled: true 1782 | 1783 | Style/StringChars: 1784 | Enabled: true 1785 | 1786 | Style/StringConcatenation: 1787 | Enabled: false 1788 | 1789 | Style/StringHashKeys: 1790 | Enabled: false 1791 | 1792 | Style/StringLiterals: 1793 | Enabled: true 1794 | EnforcedStyle: double_quotes 1795 | ConsistentQuotesInMultiline: false 1796 | 1797 | Style/StringLiteralsInInterpolation: 1798 | Enabled: true 1799 | EnforcedStyle: double_quotes 1800 | 1801 | Style/StringMethods: 1802 | Enabled: false 1803 | 1804 | Style/Strip: 1805 | Enabled: true 1806 | 1807 | Style/StructInheritance: 1808 | Enabled: false 1809 | 1810 | Style/SuperArguments: 1811 | Enabled: true 1812 | 1813 | Style/SuperWithArgsParentheses: 1814 | Enabled: true 1815 | 1816 | Style/SwapValues: 1817 | Enabled: false 1818 | 1819 | Style/SymbolArray: 1820 | Enabled: false 1821 | 1822 | Style/SymbolLiteral: 1823 | Enabled: true 1824 | 1825 | Style/SymbolProc: 1826 | Enabled: false 1827 | 1828 | Style/TernaryParentheses: 1829 | Enabled: true 1830 | EnforcedStyle: require_parentheses_when_complex 1831 | AllowSafeAssignment: true 1832 | 1833 | Style/TopLevelMethodDefinition: 1834 | Enabled: false 1835 | 1836 | Style/TrailingBodyOnClass: 1837 | Enabled: true 1838 | 1839 | Style/TrailingBodyOnMethodDefinition: 1840 | Enabled: true 1841 | 1842 | Style/TrailingBodyOnModule: 1843 | Enabled: true 1844 | 1845 | Style/TrailingCommaInArguments: 1846 | Enabled: true 1847 | EnforcedStyleForMultiline: no_comma 1848 | 1849 | Style/TrailingCommaInArrayLiteral: 1850 | Enabled: true 1851 | EnforcedStyleForMultiline: no_comma 1852 | 1853 | Style/TrailingCommaInBlockArgs: 1854 | Enabled: true 1855 | 1856 | Style/TrailingCommaInHashLiteral: 1857 | Enabled: true 1858 | EnforcedStyleForMultiline: no_comma 1859 | 1860 | Style/TrailingMethodEndStatement: 1861 | Enabled: true 1862 | 1863 | Style/TrailingUnderscoreVariable: 1864 | Enabled: false 1865 | 1866 | Style/TrivialAccessors: 1867 | Enabled: true 1868 | ExactNameMatch: true 1869 | AllowPredicates: true 1870 | AllowDSLWriters: false 1871 | IgnoreClassMethods: true 1872 | AllowedMethods: 1873 | - to_ary 1874 | - to_a 1875 | - to_c 1876 | - to_enum 1877 | - to_h 1878 | - to_hash 1879 | - to_i 1880 | - to_int 1881 | - to_io 1882 | - to_open 1883 | - to_path 1884 | - to_proc 1885 | - to_r 1886 | - to_regexp 1887 | - to_str 1888 | - to_s 1889 | - to_sym 1890 | 1891 | Style/UnlessElse: 1892 | Enabled: true 1893 | 1894 | Style/UnlessLogicalOperators: 1895 | Enabled: true 1896 | EnforcedStyle: forbid_mixed_logical_operators 1897 | 1898 | Style/UnpackFirst: 1899 | Enabled: true 1900 | 1901 | Style/VariableInterpolation: 1902 | Enabled: true 1903 | 1904 | Style/WhenThen: 1905 | Enabled: true 1906 | 1907 | Style/WhileUntilDo: 1908 | Enabled: true 1909 | 1910 | Style/WhileUntilModifier: 1911 | Enabled: false 1912 | 1913 | Style/WordArray: 1914 | Enabled: false 1915 | 1916 | Style/YAMLFileRead: 1917 | Enabled: true 1918 | 1919 | Style/YodaCondition: 1920 | Enabled: true 1921 | EnforcedStyle: forbid_for_all_comparison_operators 1922 | 1923 | Style/YodaExpression: 1924 | Enabled: false 1925 | 1926 | Style/ZeroLengthPredicate: 1927 | Enabled: false 1928 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in granity.gemspec. 5 | gemspec 6 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | granity (0.1.3) 5 | activerecord (>= 7.1) 6 | railties (>= 7.1) 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | actionpack (8.0.2) 12 | actionview (= 8.0.2) 13 | activesupport (= 8.0.2) 14 | nokogiri (>= 1.8.5) 15 | rack (>= 2.2.4) 16 | rack-session (>= 1.0.1) 17 | rack-test (>= 0.6.3) 18 | rails-dom-testing (~> 2.2) 19 | rails-html-sanitizer (~> 1.6) 20 | useragent (~> 0.16) 21 | actionview (8.0.2) 22 | activesupport (= 8.0.2) 23 | builder (~> 3.1) 24 | erubi (~> 1.11) 25 | rails-dom-testing (~> 2.2) 26 | rails-html-sanitizer (~> 1.6) 27 | activemodel (8.0.2) 28 | activesupport (= 8.0.2) 29 | activerecord (8.0.2) 30 | activemodel (= 8.0.2) 31 | activesupport (= 8.0.2) 32 | timeout (>= 0.4.0) 33 | activesupport (8.0.2) 34 | base64 35 | benchmark (>= 0.3) 36 | bigdecimal 37 | concurrent-ruby (~> 1.0, >= 1.3.1) 38 | connection_pool (>= 2.2.5) 39 | drb 40 | i18n (>= 1.6, < 2) 41 | logger (>= 1.4.2) 42 | minitest (>= 5.1) 43 | securerandom (>= 0.3) 44 | tzinfo (~> 2.0, >= 2.0.5) 45 | uri (>= 0.13.1) 46 | ast (2.4.3) 47 | base64 (0.2.0) 48 | benchmark (0.4.0) 49 | bigdecimal (3.1.9) 50 | builder (3.3.0) 51 | concurrent-ruby (1.3.5) 52 | connection_pool (2.5.0) 53 | crass (1.0.6) 54 | database_cleaner-active_record (2.2.0) 55 | activerecord (>= 5.a) 56 | database_cleaner-core (~> 2.0.0) 57 | database_cleaner-core (2.0.1) 58 | date (3.4.1) 59 | diff-lcs (1.6.0) 60 | drb (2.2.1) 61 | erubi (1.13.1) 62 | factory_bot (6.5.1) 63 | activesupport (>= 6.1.0) 64 | factory_bot_rails (6.4.4) 65 | factory_bot (~> 6.5) 66 | railties (>= 5.0.0) 67 | i18n (1.14.7) 68 | concurrent-ruby (~> 1.0) 69 | io-console (0.8.0) 70 | irb (1.15.1) 71 | pp (>= 0.6.0) 72 | rdoc (>= 4.0.0) 73 | reline (>= 0.4.2) 74 | json (2.10.2) 75 | language_server-protocol (3.17.0.4) 76 | lint_roller (1.1.0) 77 | logger (1.6.6) 78 | loofah (2.24.0) 79 | crass (~> 1.0.2) 80 | nokogiri (>= 1.12.0) 81 | minitest (5.25.5) 82 | nokogiri (1.18.4-aarch64-linux-gnu) 83 | racc (~> 1.4) 84 | nokogiri (1.18.4-aarch64-linux-musl) 85 | racc (~> 1.4) 86 | nokogiri (1.18.4-arm-linux-gnu) 87 | racc (~> 1.4) 88 | nokogiri (1.18.4-arm-linux-musl) 89 | racc (~> 1.4) 90 | nokogiri (1.18.4-arm64-darwin) 91 | racc (~> 1.4) 92 | nokogiri (1.18.4-x86_64-darwin) 93 | racc (~> 1.4) 94 | nokogiri (1.18.4-x86_64-linux-gnu) 95 | racc (~> 1.4) 96 | nokogiri (1.18.4-x86_64-linux-musl) 97 | racc (~> 1.4) 98 | parallel (1.27.0) 99 | parser (3.3.8.0) 100 | ast (~> 2.4.1) 101 | racc 102 | pp (0.6.2) 103 | prettyprint 104 | prettyprint (0.2.0) 105 | prism (1.4.0) 106 | psych (5.2.3) 107 | date 108 | stringio 109 | racc (1.8.1) 110 | rack (3.1.12) 111 | rack-session (2.1.0) 112 | base64 (>= 0.1.0) 113 | rack (>= 3.0.0) 114 | rack-test (2.2.0) 115 | rack (>= 1.3) 116 | rackup (2.2.1) 117 | rack (>= 3) 118 | rails-dom-testing (2.2.0) 119 | activesupport (>= 5.0.0) 120 | minitest 121 | nokogiri (>= 1.6) 122 | rails-html-sanitizer (1.6.2) 123 | loofah (~> 2.21) 124 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 125 | railties (8.0.2) 126 | actionpack (= 8.0.2) 127 | activesupport (= 8.0.2) 128 | irb (~> 1.13) 129 | rackup (>= 1.0.0) 130 | rake (>= 12.2) 131 | thor (~> 1.0, >= 1.2.2) 132 | zeitwerk (~> 2.6) 133 | rainbow (3.1.1) 134 | rake (13.2.1) 135 | rdoc (6.12.0) 136 | psych (>= 4.0.0) 137 | regexp_parser (2.10.0) 138 | reline (0.6.0) 139 | io-console (~> 0.5) 140 | rspec-core (3.13.3) 141 | rspec-support (~> 3.13.0) 142 | rspec-expectations (3.13.3) 143 | diff-lcs (>= 1.2.0, < 2.0) 144 | rspec-support (~> 3.13.0) 145 | rspec-mocks (3.13.2) 146 | diff-lcs (>= 1.2.0, < 2.0) 147 | rspec-support (~> 3.13.0) 148 | rspec-rails (7.1.1) 149 | actionpack (>= 7.0) 150 | activesupport (>= 7.0) 151 | railties (>= 7.0) 152 | rspec-core (~> 3.13) 153 | rspec-expectations (~> 3.13) 154 | rspec-mocks (~> 3.13) 155 | rspec-support (~> 3.13) 156 | rspec-support (3.13.2) 157 | rubocop (1.75.3) 158 | json (~> 2.3) 159 | language_server-protocol (~> 3.17.0.2) 160 | lint_roller (~> 1.1.0) 161 | parallel (~> 1.10) 162 | parser (>= 3.3.0.2) 163 | rainbow (>= 2.2.2, < 4.0) 164 | regexp_parser (>= 2.9.3, < 3.0) 165 | rubocop-ast (>= 1.44.0, < 2.0) 166 | ruby-progressbar (~> 1.7) 167 | unicode-display_width (>= 2.4.0, < 4.0) 168 | rubocop-ast (1.44.1) 169 | parser (>= 3.3.7.2) 170 | prism (~> 1.4) 171 | ruby-progressbar (1.13.0) 172 | securerandom (0.4.1) 173 | sqlite3 (2.6.0-aarch64-linux-gnu) 174 | sqlite3 (2.6.0-aarch64-linux-musl) 175 | sqlite3 (2.6.0-arm-linux-gnu) 176 | sqlite3 (2.6.0-arm-linux-musl) 177 | sqlite3 (2.6.0-arm64-darwin) 178 | sqlite3 (2.6.0-x86_64-darwin) 179 | sqlite3 (2.6.0-x86_64-linux-gnu) 180 | sqlite3 (2.6.0-x86_64-linux-musl) 181 | stringio (3.1.5) 182 | thor (1.3.2) 183 | timeout (0.4.3) 184 | tzinfo (2.0.6) 185 | concurrent-ruby (~> 1.0) 186 | unicode-display_width (3.1.4) 187 | unicode-emoji (~> 4.0, >= 4.0.4) 188 | unicode-emoji (4.0.4) 189 | uri (1.0.3) 190 | useragent (0.16.11) 191 | zeitwerk (2.7.2) 192 | 193 | PLATFORMS 194 | aarch64-linux-gnu 195 | aarch64-linux-musl 196 | arm-linux-gnu 197 | arm-linux-musl 198 | arm64-darwin 199 | x86_64-darwin 200 | x86_64-linux-gnu 201 | x86_64-linux-musl 202 | 203 | DEPENDENCIES 204 | database_cleaner-active_record 205 | factory_bot_rails 206 | granity! 207 | rspec-rails 208 | rubocop 209 | sqlite3 210 | 211 | BUNDLED WITH 212 | 2.6.5 213 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright TODO: Write your name 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Granity 2 | 3 | Granity is a fine-grained authorization engine for Ruby on Rails applications. It provides a flexible DSL for defining authorization rules and efficient permission checking. 4 | 5 | ## Installation 6 | 7 | Add this gem to your application's Gemfile: 8 | 9 | ```ruby 10 | gem 'granity' 11 | ``` 12 | 13 | Then execute: 14 | 15 | ```bash 16 | $ bundle install 17 | ``` 18 | 19 | Run the migrations: 20 | 21 | ```bash 22 | rails granity:install:migrations 23 | rails db:migrate 24 | ``` 25 | 26 | ## Configuration 27 | 28 | Create an initializer for Granity: 29 | 30 | ```ruby 31 | # config/initializers/granity.rb 32 | Granity.configure do |config| 33 | config.cache_provider = Rails.cache # Optional: Uses Rails.cache if provided 34 | config.cache_ttl = 10.minutes 35 | config.max_cache_size = 10_000 36 | config.enable_tracing = !Rails.env.production? 37 | config.max_traversal_depth = 10 38 | end 39 | ``` 40 | 41 | ## Defining Authorization Schema 42 | 43 | Use the Granity DSL to define your authorization schema: 44 | 45 | ```ruby 46 | # config/initializers/granity.rb 47 | Granity.define do 48 | resource_type :user do 49 | # User schema 50 | end 51 | 52 | resource_type :document do 53 | relation :owner, type: :user 54 | relation :viewer, type: :user 55 | relation :team, type: :team 56 | 57 | permission :view do 58 | include_any do 59 | include_relation :owner 60 | include_relation :viewer 61 | include_relation :admin from :team 62 | end 63 | end 64 | 65 | permission :edit do 66 | include_relation :owner 67 | end 68 | end 69 | 70 | resource_type :team do 71 | relation :member, type: :user 72 | relation :admin, type: :user 73 | end 74 | end 75 | ``` 76 | 77 | ## Usage 78 | 79 | ### Checking Permissions 80 | 81 | ```ruby 82 | # Check if a user has permission on a resource 83 | if Granity.check_permission( 84 | subject_type: 'user', 85 | subject_id: current_user.id, 86 | permission: 'view', 87 | resource_type: 'document', 88 | resource_id: document.id 89 | ) 90 | # User can view the document 91 | end 92 | ``` 93 | 94 | ### Creating Relations 95 | 96 | ```ruby 97 | # Grant a user owner access to a document 98 | Granity.create_relation( 99 | object_type: 'document', 100 | object_id: document.id, 101 | relation: 'owner', 102 | subject_type: 'user', 103 | subject_id: user.id 104 | ) 105 | ``` 106 | 107 | ### Finding Subjects 108 | 109 | ```ruby 110 | # Find all users who can view a document 111 | viewers = Granity.find_subjects( 112 | resource_type: 'document', 113 | resource_id: document.id, 114 | permission: 'view' 115 | ) 116 | ``` 117 | 118 | ### Integration with Rails Controllers 119 | 120 | ```ruby 121 | class ApplicationController < ActionController::Base 122 | def authorize!(resource, permission) 123 | unless Granity.check_permission( 124 | subject_type: 'user', 125 | subject_id: current_user.id, 126 | permission: permission, 127 | resource_type: resource.model_name.singular, 128 | resource_id: resource.id 129 | ) 130 | raise Unauthorized, "Not authorized to #{permission} this #{resource.model_name.human}" 131 | end 132 | end 133 | end 134 | 135 | class DocumentsController < ApplicationController 136 | def show 137 | @document = Document.find(params[:id]) 138 | authorize!(@document, :view) 139 | # ... 140 | end 141 | 142 | def update 143 | @document = Document.find(params[:id]) 144 | authorize!(@document, :edit) 145 | # ... 146 | end 147 | end 148 | ``` 149 | 150 | ## DSL Reference 151 | 152 | ### Resource Types 153 | 154 | Define the entities in your authorization model: 155 | 156 | ```ruby 157 | resource_type :document do 158 | # Resource definition 159 | end 160 | ``` 161 | 162 | ### Relations 163 | 164 | Define relationships between resources: 165 | 166 | ```ruby 167 | relation :owner, type: :user 168 | relation :parent_folder, type: :folder 169 | ``` 170 | 171 | ### Permissions 172 | 173 | Define access rules with Boolean logic: 174 | 175 | ```ruby 176 | permission :view do 177 | include_any do 178 | include_relation :owner 179 | include_relation :viewer 180 | include_relation :editor 181 | end 182 | end 183 | ``` 184 | 185 | ### Boolean Logic 186 | 187 | Combine relations with `include_any` (OR) and `include_all` (AND): 188 | 189 | ```ruby 190 | permission :publish do 191 | include_all do 192 | include_relation :editor 193 | 194 | include_any do 195 | include_relation :approved 196 | include_relation :admin from :team 197 | end 198 | end 199 | end 200 | ``` 201 | 202 | ### Permission Composition 203 | 204 | Reuse and compose permissions: 205 | 206 | ```ruby 207 | permission :manage do 208 | include_permission :view 209 | include_permission :edit 210 | include_relation :owner 211 | end 212 | ``` 213 | 214 | ### Relation Traversal 215 | 216 | Follow paths through related resources: 217 | 218 | ```ruby 219 | include_relation :member from :team 220 | ``` 221 | 222 | ## License 223 | 224 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 225 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/setup" 2 | 3 | APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__) 4 | load "rails/tasks/engine.rake" 5 | 6 | load "rails/tasks/statistics.rake" 7 | 8 | require "bundler/gem_tasks" 9 | -------------------------------------------------------------------------------- /app/models/granity/relation_tuple.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | class RelationTuple < ActiveRecord::Base 3 | self.table_name = "granity_relation_tuples" 4 | 5 | validates :object_type, :object_id, :relation, :subject_type, :subject_id, presence: true 6 | 7 | # Useful scopes for querying 8 | scope :for_object, ->(type, id) { where(object_type: type, object_id: id) } 9 | scope :for_subject, ->(type, id) { where(subject_type: type, subject_id: id) } 10 | scope :with_relation, ->(relation) { where(relation: relation) } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/granity/engine", __dir__) 7 | APP_PATH = File.expand_path("../spec/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails" 14 | # Pick the frameworks you want: 15 | require "active_model/railtie" 16 | # require "active_job/railtie" 17 | require "active_record/railtie" 18 | # require "active_storage/engine" 19 | # require "action_controller/railtie" 20 | # require "action_mailer/railtie" 21 | # require "action_mailbox/engine" 22 | # require "action_text/engine" 23 | # require "action_view/railtie" 24 | # require "action_cable/engine" 25 | # require "rails/test_unit/railtie" 26 | require "rails/engine/commands" 27 | -------------------------------------------------------------------------------- /bin/rubocop: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "rubygems" 3 | require "bundler/setup" 4 | 5 | # explicit rubocop config increases performance slightly while avoiding config confusion. 6 | ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__)) 7 | 8 | load Gem.bin_path("rubocop", "rubocop") 9 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eu 3 | cd "$(dirname "${BASH_SOURCE[0]}")" 4 | 5 | bundle 6 | 7 | echo "Creating databases..." 8 | 9 | rails db:reset -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Granity::Engine.routes.draw do 2 | end 3 | -------------------------------------------------------------------------------- /db/migrate/20250317000000_create_granity_relation_tuples.rb: -------------------------------------------------------------------------------- 1 | class CreateGranityRelationTuples < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :granity_relation_tuples do |t| 4 | t.string :object_type, null: false 5 | t.string :object_id, null: false 6 | t.string :relation, null: false 7 | t.string :subject_type, null: false 8 | t.string :subject_id, null: false 9 | 10 | t.timestamps 11 | end 12 | 13 | add_index :granity_relation_tuples, [:object_type, :object_id, :relation], 14 | name: "index_granity_tuples_on_object" 15 | add_index :granity_relation_tuples, [:subject_type, :subject_id], 16 | name: "index_granity_tuples_on_subject" 17 | add_index :granity_relation_tuples, [:object_type, :object_id, :relation, :subject_type, :subject_id], 18 | unique: true, name: "index_granity_tuples_unique" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /granity.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/granity/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "granity" 5 | spec.version = Granity::VERSION 6 | spec.authors = ["Yatish Mehta"] 7 | spec.email = ["yatish27@users.noreply.github.com"] 8 | spec.homepage = "https://github.com/yatish27/granity" 9 | spec.summary = "Fine-grained authorization for Ruby on Rails" 10 | spec.description = "Granity is a flexible, caching-friendly authorization engine that provides fine-grained access control for Ruby on Rails applications" 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.1" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["documentation_uri"] = "https://github.com/yatish27/granity/blob/main/README.md" 16 | spec.metadata["changelog_uri"] = "https://github.com/yatish27/granity/blob/main/CHANGELOG.md" 17 | 18 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 19 | 20 | rails_version = ">= 7.1" 21 | spec.add_dependency "activerecord", rails_version 22 | spec.add_dependency "railties", rails_version 23 | 24 | spec.add_development_dependency "sqlite3" 25 | spec.add_development_dependency "rspec-rails" 26 | spec.add_development_dependency "factory_bot_rails" 27 | spec.add_development_dependency "database_cleaner-active_record" 28 | spec.add_development_dependency "rubocop" 29 | end 30 | -------------------------------------------------------------------------------- /lib/generators/granity/install/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | The granity:install generator creates the necessary migration file for 3 | the Granity authorization engine and adds a default initializer. 4 | 5 | Example: 6 | bin/rails generate granity:install 7 | 8 | This will create: 9 | db/migrate/TIMESTAMP_create_granity_tables.rb 10 | config/initializers/granity.rb -------------------------------------------------------------------------------- /lib/generators/granity/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | module Generators 3 | class InstallGenerator < Rails::Generators::Base 4 | source_root File.expand_path("templates", __dir__) 5 | 6 | def create_migration 7 | timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S") 8 | copy_file "create_granity_tables.rb", "db/migrate/#{timestamp}_create_granity_tables.rb" 9 | end 10 | 11 | def create_initializer 12 | copy_file "initializer.rb", "config/initializers/granity.rb" 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/generators/granity/install/templates/create_granity_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateGranityTables < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :granity_relation_tuples do |t| 4 | t.string :object_type, null: false 5 | t.string :object_id, null: false 6 | t.string :relation, null: false 7 | t.string :subject_type, null: false 8 | t.string :subject_id, null: false 9 | 10 | t.timestamps 11 | end 12 | 13 | add_index :granity_relation_tuples, [:object_type, :object_id, :relation], 14 | name: "index_granity_tuples_on_object" 15 | add_index :granity_relation_tuples, [:subject_type, :subject_id], 16 | name: "index_granity_tuples_on_subject" 17 | add_index :granity_relation_tuples, [:object_type, :object_id, :relation, :subject_type, :subject_id], 18 | unique: true, name: "index_granity_tuples_unique" 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/generators/granity/install/templates/initializer.rb: -------------------------------------------------------------------------------- 1 | Granity.configure do |config| 2 | # Configure cache provider (defaults to nil) 3 | # config.cache_provider = Rails.cache 4 | 5 | # Configure cache TTL (defaults to 10 minutes) 6 | # config.cache_ttl = 10.minutes 7 | 8 | # Configure max cache size (defaults to 10,000) 9 | # config.max_cache_size = 10_000 10 | 11 | # Enable tracing (defaults to true in non-production) 12 | # config.enable_tracing = !Rails.env.production? 13 | 14 | # Configure max traversal depth (defaults to 10) 15 | # config.max_traversal_depth = 10 16 | end 17 | 18 | # Define your authorization schema 19 | # Granity.define do 20 | # # Add your resource types, relations, and permissions here 21 | # end 22 | -------------------------------------------------------------------------------- /lib/granity.rb: -------------------------------------------------------------------------------- 1 | require "granity/version" 2 | require "granity/configuration" 3 | require "granity/schema" 4 | require "granity/resource_type" 5 | require "granity/relation" 6 | require "granity/permission" 7 | require "granity/rules" 8 | require "granity/in_memory_cache" 9 | require "granity/dependency_analyzer" 10 | require "granity/permission_evaluator" 11 | require "granity/authorization_engine" 12 | # Rails engine is loaded at the end 13 | require "granity/engine" if defined?(Rails) 14 | 15 | module Granity 16 | class Error < StandardError; end 17 | 18 | class << self 19 | def configuration 20 | @configuration ||= Configuration.new 21 | end 22 | 23 | # Entry point for DSL schema definition 24 | def define(&block) 25 | Schema.define(&block) 26 | end 27 | 28 | # Configuration setup 29 | def configure 30 | yield(configuration) if block_given? 31 | configuration 32 | end 33 | 34 | # Public API to check permissions 35 | def check_permission(subject_type:, subject_id:, permission:, resource_type:, resource_id:) 36 | AuthorizationEngine.check_permission( 37 | subject_type: subject_type, 38 | subject_id: subject_id, 39 | permission: permission, 40 | resource_type: resource_type, 41 | resource_id: resource_id 42 | ) 43 | end 44 | 45 | # Public API to find subjects with a permission 46 | def find_subjects(resource_type:, resource_id:, permission:) 47 | AuthorizationEngine.find_subjects( 48 | resource_type: resource_type, 49 | resource_id: resource_id, 50 | permission: permission 51 | ) 52 | end 53 | 54 | # Public API to create relation tuples 55 | def create_relation(object_type:, object_id:, relation:, subject_type:, subject_id:) 56 | AuthorizationEngine.create_relation( 57 | object_type: object_type, 58 | object_id: object_id, 59 | relation: relation, 60 | subject_type: subject_type, 61 | subject_id: subject_id 62 | ) 63 | end 64 | 65 | # Public API to delete relation tuples 66 | def delete_relation(object_type:, object_id:, relation:, subject_type:, subject_id:) 67 | AuthorizationEngine.delete_relation( 68 | object_type: object_type, 69 | object_id: object_id, 70 | relation: relation, 71 | subject_type: subject_type, 72 | subject_id: subject_id 73 | ) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/granity/authorization_engine.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | class AuthorizationEngine 3 | class << self 4 | def check_permission(subject_type:, subject_id:, permission:, resource_type:, resource_id:) 5 | cache_key = "granity:permission:#{subject_type}:#{subject_id}:#{permission}:#{resource_type}:#{resource_id}" 6 | 7 | # Try fetching from cache first 8 | cached_result = cache.read(cache_key) 9 | if cached_result 10 | trace("CACHE HIT: #{cache_key} -> #{cached_result}") 11 | return cached_result 12 | end 13 | 14 | trace("CACHE MISS: #{cache_key}") 15 | 16 | # Generate dependencies for this permission check 17 | dependencies = DependencyAnalyzer.analyze_permission_check( 18 | subject_type: subject_type, 19 | subject_id: subject_id, 20 | permission: permission, 21 | resource_type: resource_type, 22 | resource_id: resource_id 23 | ) 24 | 25 | # Check the permission 26 | result = PermissionEvaluator.evaluate( 27 | subject_type: subject_type, 28 | subject_id: subject_id, 29 | permission: permission, 30 | resource_type: resource_type, 31 | resource_id: resource_id 32 | ) 33 | 34 | # Store in cache with all dependency keys 35 | cache.write(cache_key, result, dependencies: dependencies) 36 | trace("CACHE WRITE: #{cache_key} -> #{result} with dependencies: #{dependencies}") 37 | 38 | result 39 | end 40 | 41 | def find_subjects(resource_type:, resource_id:, permission:) 42 | cache_key = "granity:subjects:#{permission}:#{resource_type}:#{resource_id}" 43 | 44 | # Try fetching from cache first 45 | cached_result = cache.read(cache_key) 46 | if cached_result 47 | trace("CACHE HIT: #{cache_key}") 48 | return cached_result 49 | end 50 | 51 | trace("CACHE MISS: #{cache_key}") 52 | 53 | # Generate dependencies for this subjects query 54 | dependencies = DependencyAnalyzer.analyze_find_subjects( 55 | resource_type: resource_type, 56 | resource_id: resource_id, 57 | permission: permission 58 | ) 59 | 60 | # Get the subjects 61 | subjects = PermissionEvaluator.find_subjects( 62 | resource_type: resource_type, 63 | resource_id: resource_id, 64 | permission: permission 65 | ) 66 | 67 | # Store in cache with all dependency keys 68 | cache.write(cache_key, subjects, dependencies: dependencies) 69 | trace("CACHE WRITE: #{cache_key} with dependencies: #{dependencies}") 70 | 71 | subjects 72 | end 73 | 74 | def create_relation(object_type:, object_id:, relation:, subject_type:, subject_id:) 75 | # Create the relation tuple in the database 76 | tuple = Granity::RelationTuple.create!( 77 | object_type: object_type, 78 | object_id: object_id, 79 | relation: relation, 80 | subject_type: subject_type, 81 | subject_id: subject_id 82 | ) 83 | 84 | # Invalidate cache entries that depend on this relation 85 | invalidate_cache_for_relation(object_type, object_id, relation) 86 | 87 | tuple 88 | rescue ActiveRecord::RecordNotUnique 89 | # If the relation already exists, just return it 90 | Granity::RelationTuple.find_by( 91 | object_type: object_type, 92 | object_id: object_id, 93 | relation: relation, 94 | subject_type: subject_type, 95 | subject_id: subject_id 96 | ) 97 | end 98 | 99 | def delete_relation(object_type:, object_id:, relation:, subject_type:, subject_id:) 100 | tuple = Granity::RelationTuple.find_by( 101 | object_type: object_type, 102 | object_id: object_id, 103 | relation: relation, 104 | subject_type: subject_type, 105 | subject_id: subject_id 106 | ) 107 | 108 | return false unless tuple 109 | 110 | tuple.destroy 111 | 112 | # Invalidate cache entries that depend on this relation 113 | invalidate_cache_for_relation(object_type, object_id, relation) 114 | 115 | true 116 | end 117 | 118 | def reset_cache 119 | cache.clear 120 | end 121 | 122 | private 123 | 124 | def cache 125 | @cache ||= begin 126 | config = Granity.configuration 127 | config.cache_provider || Granity::InMemoryCache.new( 128 | max_size: config.max_cache_size, 129 | ttl: config.cache_ttl 130 | ) 131 | end 132 | end 133 | 134 | def invalidate_cache_for_relation(object_type, object_id, relation) 135 | # This is a simplified approach. In a real implementation, we would use 136 | # a more sophisticated approach to track which cache keys depend on which 137 | # relations, possibly using Redis sets or a similar mechanism. 138 | 139 | # For now, we take a conservative approach and invalidate any cache entry 140 | # that might depend on this relation being changed 141 | dependency_key = "granity:relation:#{object_type}:#{object_id}:#{relation}" 142 | cache.invalidate_dependencies([dependency_key]) 143 | 144 | trace("CACHE INVALIDATE for dependency: #{dependency_key}") 145 | end 146 | 147 | def trace(message) 148 | return unless Granity.configuration.enable_tracing 149 | 150 | # In a real implementation, this would use a proper logging system 151 | puts "[Granity] #{message}" 152 | end 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /lib/granity/configuration.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Configuration class to hold Granity settings 3 | class Configuration 4 | attr_accessor :cache_provider 5 | attr_accessor :cache_ttl 6 | attr_accessor :max_cache_size 7 | attr_accessor :enable_tracing 8 | attr_accessor :max_traversal_depth 9 | 10 | def initialize 11 | @cache_provider = nil 12 | @cache_ttl = 10.minutes 13 | @max_cache_size = 10_000 14 | @enable_tracing = !defined?(Rails) || !Rails.env.production? 15 | @max_traversal_depth = 10 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/granity/dependency_analyzer.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Analyzes dependencies between relations and permissions for proper cache invalidation 3 | class DependencyAnalyzer 4 | class << self 5 | # Analyze dependencies for a permission check 6 | # Returns an array of dependencies for cache invalidation 7 | def analyze_permission_check(subject_type:, subject_id:, permission:, resource_type:, resource_id:) 8 | deps = [] 9 | 10 | # Basic direct dependencies 11 | deps << "granity:subject:#{subject_type}:#{subject_id}" 12 | deps << "granity:resource:#{resource_type}:#{resource_id}" 13 | 14 | # Get schema dependencies for this permission 15 | schema_dependencies = analyze_permission_schema(resource_type, permission) 16 | deps.concat(schema_dependencies) 17 | 18 | deps 19 | end 20 | 21 | # Analyze dependencies for finding subjects with a permission 22 | def analyze_find_subjects(resource_type:, resource_id:, permission:) 23 | deps = [] 24 | 25 | # Basic resource dependency 26 | deps << "granity:resource:#{resource_type}:#{resource_id}" 27 | 28 | # Get schema dependencies for this permission 29 | schema_dependencies = analyze_permission_schema(resource_type, permission) 30 | deps.concat(schema_dependencies) 31 | 32 | deps 33 | end 34 | 35 | private 36 | 37 | # Analyze schema dependencies for a permission 38 | def analyze_permission_schema(resource_type, permission) 39 | deps = [] 40 | schema = Granity::Schema.current 41 | 42 | # Add dependency on the permission definition itself 43 | deps << "granity:schema:#{resource_type}:permission:#{permission}" 44 | 45 | # Get the resource type from schema 46 | resource_type_def = schema.resource_types[resource_type.to_sym] 47 | return deps unless resource_type_def 48 | 49 | # Get the permission definition 50 | permission_def = resource_type_def.permissions[permission.to_sym] 51 | return deps unless permission_def 52 | 53 | # Track visited permissions to prevent cycles 54 | visited = Set.new(["#{resource_type}:#{permission}"]) 55 | 56 | # Add all relations that this permission depends on 57 | relations = extract_relations_from_permission(permission_def, [], visited) 58 | relations.each do |relation| 59 | deps << "granity:relation:#{resource_type}:#{relation}" 60 | end 61 | 62 | deps 63 | end 64 | 65 | # Extract all relations used in a permission definition 66 | def extract_relations_from_permission(rule_or_permission, relations = [], visited = Set.new) 67 | # Handle different types of input 68 | if rule_or_permission.is_a?(Granity::Permission) 69 | # It's a Permission object with rules 70 | rule_or_permission.rules.each do |rule| 71 | extract_relations_from_rule(rule, relations, visited, rule_or_permission.resource_type) 72 | end 73 | else 74 | # It's a rule object directly 75 | extract_relations_from_rule(rule_or_permission, relations, visited, nil) 76 | end 77 | 78 | relations.uniq 79 | end 80 | 81 | # Process a single rule to extract relations 82 | def extract_relations_from_rule(rule, relations = [], visited = Set.new, resource_type = nil) 83 | if rule.is_a?(Granity::Rules::Relation) 84 | # Direct relation - add to results 85 | relations << rule.relation 86 | elsif rule.is_a?(Granity::Rules::Any) || rule.is_a?(Granity::Rules::All) 87 | # Container rule - process each subrule 88 | rule.rules.each do |subrule| 89 | extract_relations_from_rule(subrule, relations, visited, resource_type) 90 | end 91 | elsif rule.is_a?(Granity::Rules::Permission) 92 | # Referenced permission - get from schema and process 93 | # Skip if we've already visited this permission to prevent cycles 94 | permission_key = "#{resource_type}:#{rule.permission}" 95 | return relations if visited.include?(permission_key) 96 | 97 | # Mark as visited to prevent cycles 98 | visited.add(permission_key) 99 | 100 | # Get referenced permission from schema (if possible) 101 | schema = Granity::Schema.current 102 | 103 | if resource_type && schema.resource_types[resource_type.to_sym] 104 | # If we know the resource type, look for the permission there 105 | resource_type_def = schema.resource_types[resource_type.to_sym] 106 | if resource_type_def.permissions.has_key?(rule.permission) 107 | referenced_permission = resource_type_def.permissions[rule.permission] 108 | extract_relations_from_permission(referenced_permission, relations, visited) 109 | end 110 | else 111 | # If resource type is unknown, look in all resource types 112 | resource_types = schema.resource_types.values 113 | resource_types.each do |rt| 114 | if rt.permissions.has_key?(rule.permission) 115 | referenced_permission = rt.permissions[rule.permission] 116 | extract_relations_from_permission(referenced_permission, relations, visited) 117 | break 118 | end 119 | end 120 | end 121 | end 122 | 123 | relations 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/granity/engine.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | class Engine < ::Rails::Engine 3 | isolate_namespace Granity 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/granity/in_memory_cache.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Simple in-memory cache with dependency tracking and TTL 3 | class InMemoryCache 4 | def initialize(max_size: 10_000, ttl: 600) 5 | @data = {} 6 | @dependencies = {} 7 | @max_size = max_size 8 | @default_ttl = ttl 9 | @mutex = Mutex.new 10 | end 11 | 12 | # Read a value from cache 13 | def read(key) 14 | @mutex.synchronize do 15 | entry = @data[key] 16 | return nil unless entry 17 | 18 | # Check if entry is expired 19 | if entry[:expires_at] && entry[:expires_at] < Time.now 20 | @data.delete(key) 21 | return nil 22 | end 23 | 24 | entry[:value] 25 | end 26 | end 27 | 28 | # Write a value to cache with dependencies 29 | def write(key, value, dependencies: [], ttl: nil) 30 | @mutex.synchronize do 31 | # Set expiration time if ttl provided 32 | expires_at = if ttl 33 | Time.now + ttl 34 | else 35 | (@default_ttl ? Time.now + @default_ttl : nil) 36 | end 37 | 38 | # Store the value 39 | @data[key] = { 40 | value: value, 41 | expires_at: expires_at 42 | } 43 | 44 | # Register dependencies 45 | dependencies.each do |dependency| 46 | @dependencies[dependency] ||= [] 47 | @dependencies[dependency] << key unless @dependencies[dependency].include?(key) 48 | end 49 | 50 | # Enforce max size by removing oldest entries if needed 51 | if @data.size > @max_size 52 | # Simple LRU - just remove oldest N/4 entries 53 | keys_to_remove = @data.keys.take(@max_size / 4) 54 | keys_to_remove.each { |k| @data.delete(k) } 55 | end 56 | 57 | value 58 | end 59 | end 60 | 61 | # Invalidate cache entries by dependency key 62 | def invalidate_dependencies(dependency_keys) 63 | @mutex.synchronize do 64 | keys_to_invalidate = [] 65 | 66 | dependency_keys.each do |dep_key| 67 | # Get all cache keys dependent on this key 68 | if @dependencies[dep_key] 69 | keys_to_invalidate.concat(@dependencies[dep_key]) 70 | @dependencies.delete(dep_key) 71 | end 72 | end 73 | 74 | # Remove the invalidated entries 75 | keys_to_invalidate.uniq.each do |key| 76 | @data.delete(key) 77 | end 78 | 79 | keys_to_invalidate.size 80 | end 81 | end 82 | 83 | # Clear the entire cache 84 | def clear 85 | @mutex.synchronize do 86 | @data.clear 87 | @dependencies.clear 88 | end 89 | end 90 | 91 | # Get current cache size 92 | def size 93 | @mutex.synchronize { @data.size } 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /lib/granity/permission.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Definition of a permission in the authorization schema 3 | class Permission 4 | attr_reader :name, :resource_type, :description, :rules 5 | 6 | def initialize(name:, resource_type:, description: nil) 7 | @name = name 8 | @resource_type = resource_type 9 | @description = description 10 | @rules = [] 11 | end 12 | 13 | # Include a relation in the permission 14 | def include_relation(relation, from: nil) 15 | @rules << Rules::Relation.new(relation: relation, from: from) 16 | end 17 | 18 | # Include another permission in this permission 19 | def include_permission(permission, from: nil) 20 | @rules << Rules::Permission.new(permission: permission, from: from) 21 | end 22 | 23 | # Define a set of rules where ANY must match 24 | def include_any(&block) 25 | rule = Rules::Any.new 26 | rule.instance_eval(&block) 27 | @rules << rule 28 | end 29 | 30 | # Define a set of rules where ALL must match 31 | def include_all(&block) 32 | rule = Rules::All.new 33 | rule.instance_eval(&block) 34 | @rules << rule 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /lib/granity/permission_evaluator.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Evaluates permissions based on the defined schema and relation tuples 3 | class PermissionEvaluator 4 | class << self 5 | # Evaluate if a subject has a permission on a resource 6 | def evaluate(subject_type:, subject_id:, permission:, resource_type:, resource_id:) 7 | # Get the schema definition 8 | schema = Granity::Schema.current 9 | resource_type_def = schema.resource_types[resource_type.to_sym] 10 | 11 | # If resource type doesn't exist, deny permission 12 | return false unless resource_type_def 13 | 14 | # Get the permission definition 15 | permission_def = resource_type_def.permissions[permission.to_sym] 16 | 17 | # If permission doesn't exist, deny permission 18 | return false unless permission_def 19 | 20 | # Evaluate the permission rules 21 | evaluate_rules( 22 | rules: permission_def.rules, 23 | subject_type: subject_type, 24 | subject_id: subject_id, 25 | resource_type: resource_type, 26 | resource_id: resource_id 27 | ) 28 | end 29 | 30 | # Find subjects with a permission on a resource 31 | def find_subjects(resource_type:, resource_id:, permission:) 32 | # Get the schema definition 33 | schema = Granity::Schema.current 34 | resource_type_def = schema.resource_types[resource_type.to_sym] 35 | 36 | # If resource type doesn't exist, return empty array 37 | return [] unless resource_type_def 38 | 39 | # Get the permission definition 40 | permission_def = resource_type_def.permissions[permission.to_sym] 41 | 42 | # If permission doesn't exist, return empty array 43 | return [] unless permission_def 44 | 45 | # Get all relation tuples for this resource 46 | collect_subjects_for_permission( 47 | rules: permission_def.rules, 48 | resource_type: resource_type, 49 | resource_id: resource_id 50 | ) 51 | end 52 | 53 | private 54 | 55 | # Evaluate rules recursively 56 | def evaluate_rules(rules:, subject_type:, subject_id:, resource_type:, resource_id:, depth: 0) 57 | # Prevent infinite recursion 58 | max_depth = Granity.configuration.max_traversal_depth 59 | if depth > max_depth 60 | raise "Maximum permission evaluation depth (#{max_depth}) exceeded" 61 | end 62 | 63 | rules.each do |rule| 64 | case rule 65 | when Granity::Rules::Relation 66 | # Check if relation tuple exists, handling the "from" case 67 | if rule.from 68 | # Relation traversal is needed 69 | if check_relation_traversal( 70 | subject_type: subject_type, 71 | subject_id: subject_id, 72 | relation: rule.relation, 73 | from_relation: rule.from, 74 | object_type: resource_type, 75 | object_id: resource_id 76 | ) 77 | return true 78 | end 79 | elsif check_relation( 80 | subject_type: subject_type, 81 | subject_id: subject_id, 82 | relation: rule.relation, 83 | object_type: resource_type, 84 | object_id: resource_id 85 | ) 86 | # Direct relation check 87 | return true 88 | end 89 | when Granity::Rules::Permission 90 | # Check referenced permission, handling "from" case 91 | if rule.from 92 | # Permission check with traversal - check permission on the "from" related objects 93 | if check_permission_traversal( 94 | subject_type: subject_type, 95 | subject_id: subject_id, 96 | permission: rule.permission, 97 | from_relation: rule.from, 98 | object_type: resource_type, 99 | object_id: resource_id, 100 | depth: depth + 1 101 | ) 102 | return true 103 | end 104 | elsif evaluate( 105 | subject_type: subject_type, 106 | subject_id: subject_id, 107 | permission: rule.permission, 108 | resource_type: resource_type, 109 | resource_id: resource_id 110 | ) 111 | # Direct permission check on the same resource 112 | return true 113 | end 114 | when Granity::Rules::Any 115 | # Check if any of the subrules match 116 | if rule.rules.any? do |subrule| 117 | evaluate_rules( 118 | rules: [subrule], 119 | subject_type: subject_type, 120 | subject_id: subject_id, 121 | resource_type: resource_type, 122 | resource_id: resource_id, 123 | depth: depth + 1 124 | ) 125 | end 126 | return true 127 | end 128 | when Granity::Rules::All 129 | # Check if all of the subrules match 130 | if rule.rules.all? do |subrule| 131 | evaluate_rules( 132 | rules: [subrule], 133 | subject_type: subject_type, 134 | subject_id: subject_id, 135 | resource_type: resource_type, 136 | resource_id: resource_id, 137 | depth: depth + 1 138 | ) 139 | end 140 | return true 141 | end 142 | end 143 | end 144 | 145 | false 146 | end 147 | 148 | # Check if a direct relation tuple exists 149 | def check_relation(subject_type:, subject_id:, relation:, object_type:, object_id:) 150 | Granity::RelationTuple.exists?( 151 | subject_type: subject_type, 152 | subject_id: subject_id, 153 | relation: relation, 154 | object_type: object_type, 155 | object_id: object_id 156 | ) 157 | end 158 | 159 | # Check relation with traversal (the "from" case) 160 | def check_relation_traversal(subject_type:, subject_id:, relation:, from_relation:, object_type:, object_id:) 161 | # First, find all intermediary objects through the 'from' relation 162 | intermediaries = Granity::RelationTuple.where( 163 | object_type: object_type, 164 | object_id: object_id, 165 | relation: from_relation 166 | ) 167 | 168 | # For each intermediary, check if the subject has the relation to it 169 | intermediaries.any? do |tuple| 170 | Granity::RelationTuple.exists?( 171 | subject_type: subject_type, 172 | subject_id: subject_id, 173 | relation: relation, 174 | object_type: tuple.subject_type, 175 | object_id: tuple.subject_id 176 | ) 177 | end 178 | end 179 | 180 | # Check permission with traversal (the "from" case) 181 | def check_permission_traversal(subject_type:, subject_id:, permission:, from_relation:, object_type:, object_id:, depth:) 182 | # First, find all intermediary objects through the 'from' relation 183 | intermediaries = Granity::RelationTuple.where( 184 | object_type: object_type, 185 | object_id: object_id, 186 | relation: from_relation 187 | ) 188 | 189 | # For each intermediary, check if the subject has the permission on it 190 | intermediaries.any? do |tuple| 191 | evaluate( 192 | subject_type: subject_type, 193 | subject_id: subject_id, 194 | permission: permission, 195 | resource_type: tuple.subject_type, 196 | resource_id: tuple.subject_id 197 | ) 198 | end 199 | end 200 | 201 | # Collect subjects that have a permission on a resource 202 | def collect_subjects_for_permission(rules:, resource_type:, resource_id:) 203 | subjects = [] 204 | 205 | rules.each do |rule| 206 | case rule 207 | when Granity::Rules::Relation 208 | if rule.from 209 | # Handle relation traversal for finding subjects 210 | intermediaries = Granity::RelationTuple.where( 211 | object_type: resource_type, 212 | object_id: resource_id, 213 | relation: rule.from 214 | ) 215 | 216 | intermediaries.each do |tuple| 217 | # For each intermediary, find subjects with the relation 218 | relations = Granity::RelationTuple.where( 219 | object_type: tuple.subject_type, 220 | object_id: tuple.subject_id, 221 | relation: rule.relation 222 | ) 223 | 224 | relations.each do |rel| 225 | subjects << {type: rel.subject_type, id: rel.subject_id} 226 | end 227 | end 228 | else 229 | # Direct relation - find all subjects with this relation 230 | tuples = Granity::RelationTuple.where( 231 | object_type: resource_type, 232 | object_id: resource_id, 233 | relation: rule.relation 234 | ) 235 | 236 | tuples.each do |tuple| 237 | subjects << {type: tuple.subject_type, id: tuple.subject_id} 238 | end 239 | end 240 | when Granity::Rules::Permission 241 | # Find subjects with referenced permission 242 | if rule.from 243 | # Permission with traversal - we would need to traverse the "from" relation 244 | # and then find subjects with the permission on those objects 245 | # This is a simplification - in a real implementation we would handle this more efficiently 246 | intermediaries = Granity::RelationTuple.where( 247 | object_type: resource_type, 248 | object_id: resource_id, 249 | relation: rule.from 250 | ) 251 | 252 | intermediaries.each do |tuple| 253 | perm_subjects = find_subjects( 254 | resource_type: tuple.subject_type, 255 | resource_id: tuple.subject_id, 256 | permission: rule.permission 257 | ) 258 | 259 | subjects.concat(perm_subjects) 260 | end 261 | else 262 | # Direct permission check 263 | referenced_subjects = find_subjects( 264 | resource_type: resource_type, 265 | resource_id: resource_id, 266 | permission: rule.permission 267 | ) 268 | 269 | subjects.concat(referenced_subjects) 270 | end 271 | when Granity::Rules::Any, Granity::Rules::All 272 | # Recursively collect subjects for each subrule 273 | rule.rules.each do |subrule| 274 | subrule_subjects = collect_subjects_for_permission( 275 | rules: [subrule], 276 | resource_type: resource_type, 277 | resource_id: resource_id 278 | ) 279 | 280 | subjects.concat(subrule_subjects) 281 | end 282 | end 283 | end 284 | 285 | # Remove duplicates 286 | subjects.uniq { |s| "#{s[:type]}:#{s[:id]}" } 287 | end 288 | end 289 | end 290 | end 291 | -------------------------------------------------------------------------------- /lib/granity/relation.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Definition of a relation between resources 3 | class Relation 4 | attr_reader :name, :resource_type, :target_type, :description 5 | 6 | def initialize(name:, resource_type:, target_type:, description: nil) 7 | @name = name 8 | @resource_type = resource_type 9 | @target_type = target_type 10 | @description = description 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/granity/resource_type.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Definition of a resource type in the authorization schema 3 | class ResourceType 4 | attr_reader :name, :relations, :permissions 5 | 6 | def initialize(name) 7 | @name = name.to_sym 8 | @relations = {} 9 | @permissions = {} 10 | end 11 | 12 | # DSL method to define a relation 13 | def relation(name, type:, description: nil) 14 | @relations[name.to_sym] = Relation.new( 15 | name: name.to_sym, 16 | resource_type: @name, 17 | target_type: type.to_sym, 18 | description: description 19 | ) 20 | end 21 | 22 | # DSL method to define a permission 23 | def permission(name, description: nil, &block) 24 | permission = Permission.new( 25 | name: name.to_sym, 26 | resource_type: @name, 27 | description: description 28 | ) 29 | 30 | permission.instance_eval(&block) if block_given? 31 | @permissions[name.to_sym] = permission 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/granity/rules.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Rules namespace for permission evaluation rules 3 | module Rules 4 | # Base class for all rules 5 | class Base 6 | def initialize 7 | # Base initialization 8 | end 9 | end 10 | 11 | # Rule for checking relation existence 12 | class Relation < Base 13 | attr_reader :relation, :from 14 | 15 | def initialize(relation:, from: nil) 16 | @relation = relation.to_sym 17 | @from = from.to_sym if from 18 | end 19 | end 20 | 21 | # Rule for checking another permission 22 | class Permission < Base 23 | attr_reader :permission, :from 24 | 25 | def initialize(permission:, from: nil) 26 | @permission = permission.to_sym 27 | @from = from.to_sym if from 28 | end 29 | end 30 | 31 | # Rule container where ANY rule must match 32 | class Any < Base 33 | attr_reader :rules 34 | 35 | def initialize 36 | @rules = [] 37 | end 38 | 39 | def include_relation(relation, from: nil) 40 | @rules << Relation.new(relation: relation, from: from) 41 | end 42 | 43 | def include_permission(permission, from: nil) 44 | @rules << Permission.new(permission: permission, from: from) 45 | end 46 | 47 | def include_any(&block) 48 | rule = Any.new 49 | rule.instance_eval(&block) 50 | @rules << rule 51 | end 52 | 53 | def include_all(&block) 54 | rule = All.new 55 | rule.instance_eval(&block) 56 | @rules << rule 57 | end 58 | end 59 | 60 | # Rule container where ALL rules must match 61 | class All < Base 62 | attr_reader :rules 63 | 64 | def initialize 65 | @rules = [] 66 | end 67 | 68 | def include_relation(relation, from: nil) 69 | @rules << Relation.new(relation: relation, from: from) 70 | end 71 | 72 | def include_permission(permission, from: nil) 73 | @rules << Permission.new(permission: permission, from: from) 74 | end 75 | 76 | def include_any(&block) 77 | rule = Any.new 78 | rule.instance_eval(&block) 79 | @rules << rule 80 | end 81 | 82 | def include_all(&block) 83 | rule = All.new 84 | rule.instance_eval(&block) 85 | @rules << rule 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/granity/schema.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | # Schema definition for Granity authorization model 3 | class Schema 4 | attr_reader :resource_types 5 | 6 | class << self 7 | # DSL entry point for defining schema 8 | def define(&block) 9 | @current = new 10 | @current.instance_eval(&block) 11 | @current 12 | end 13 | 14 | # Get current schema 15 | def current 16 | @current ||= new 17 | end 18 | end 19 | 20 | def initialize 21 | @resource_types = {} 22 | end 23 | 24 | # DSL method to define a resource type 25 | def resource_type(name, &block) 26 | resource_type = ResourceType.new(name) 27 | resource_type.instance_eval(&block) if block_given? 28 | @resource_types[name] = resource_type 29 | end 30 | 31 | def validate_schema 32 | # Validate that all relation types reference valid resource types 33 | @resource_types.each do |name, resource_type| 34 | resource_type.relations.each do |relation_name, relation| 35 | unless @resource_types.key?(relation.type.to_sym) 36 | raise SchemaError, "Resource type '#{name}' has relation '#{relation_name}' with invalid type '#{relation.type}'" 37 | end 38 | end 39 | end 40 | end 41 | end 42 | 43 | class SchemaError < StandardError; end 44 | end 45 | -------------------------------------------------------------------------------- /lib/granity/version.rb: -------------------------------------------------------------------------------- 1 | module Granity 2 | VERSION = "0.1.3" 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/granity_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :granity do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. 3 | allow_browser versions: :modern 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <%= content_for(:title) || "Dummy" %> 5 | 6 | 7 | 8 | <%= csrf_meta_tags %> 9 | <%= csp_meta_tag %> 10 | 11 | <%= yield :head %> 12 | 13 | <%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %> 14 | <%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %> 15 | 16 | 17 | 18 | 19 | 20 | <%# Includes all stylesheet files in app/assets/stylesheets %> 21 | <%= stylesheet_link_tag :app %> 22 | 23 | 24 | 25 | <%= yield %> 26 | 27 | 28 | -------------------------------------------------------------------------------- /spec/dummy/app/views/pwa/manifest.json.erb: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dummy", 3 | "icons": [ 4 | { 5 | "src": "/icon.png", 6 | "type": "image/png", 7 | "sizes": "512x512" 8 | }, 9 | { 10 | "src": "/icon.png", 11 | "type": "image/png", 12 | "sizes": "512x512", 13 | "purpose": "maskable" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "scope": "/", 19 | "description": "Dummy.", 20 | "theme_color": "red", 21 | "background_color": "red" 22 | } 23 | -------------------------------------------------------------------------------- /spec/dummy/app/views/pwa/service-worker.js: -------------------------------------------------------------------------------- 1 | // Add a service worker for processing Web Push notifications: 2 | // 3 | // self.addEventListener("push", async (event) => { 4 | // const { title, options } = await event.data.json() 5 | // event.waitUntil(self.registration.showNotification(title, options)) 6 | // }) 7 | // 8 | // self.addEventListener("notificationclick", function(event) { 9 | // event.notification.close() 10 | // event.waitUntil( 11 | // clients.matchAll({ type: "window" }).then((clientList) => { 12 | // for (let i = 0; i < clientList.length; i++) { 13 | // let client = clientList[i] 14 | // let clientPath = (new URL(client.url)).pathname 15 | // 16 | // if (clientPath == event.notification.data.path && "focus" in client) { 17 | // return client.focus() 18 | // } 19 | // } 20 | // 21 | // if (clients.openWindow) { 22 | // return clients.openWindow(event.notification.data.path) 23 | // } 24 | // }) 25 | // ) 26 | // }) 27 | -------------------------------------------------------------------------------- /spec/dummy/bin/dev: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | exec "./bin/rails", "server", *ARGV 3 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | 6 | def system!(*args) 7 | system(*args, exception: true) 8 | end 9 | 10 | FileUtils.chdir APP_ROOT do 11 | # This script is a way to set up or update your development environment automatically. 12 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 13 | # Add necessary setup steps to this file. 14 | 15 | puts "== Installing dependencies ==" 16 | system("bundle check") || system!("bundle install") 17 | 18 | # puts "\n== Copying sample files ==" 19 | # unless File.exist?("config/database.yml") 20 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 21 | # end 22 | 23 | puts "\n== Preparing database ==" 24 | system! "bin/rails db:prepare" 25 | 26 | puts "\n== Removing old logs and tempfiles ==" 27 | system! "bin/rails log:clear tmp:clear" 28 | 29 | unless ARGV.include?("--skip-server") 30 | puts "\n== Starting development server ==" 31 | $stdout.flush # flush the output before exec(2) so that it displays 32 | exec "bin/dev" 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | # require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_mailbox/engine" 12 | # require "action_text/engine" 13 | # require "action_view/railtie" 14 | # require "action_cable/engine" 15 | require "rails/test_unit/railtie" 16 | 17 | # Require the gems listed in Gemfile, including any gems 18 | # you've limited to :test, :development, or :production. 19 | Bundler.require(*Rails.groups) 20 | 21 | module Dummy 22 | class Application < Rails::Application 23 | config.load_defaults Rails::VERSION::STRING.to_f 24 | 25 | # For compatibility with applications that use this config 26 | config.action_controller.include_all_helpers = false 27 | 28 | # Please, add to the `ignore` list any other `lib` subdirectories that do 29 | # not contain `.rb` files, or that should not be reloaded or eager loaded. 30 | # Common ones are `templates`, `generators`, or `middleware`, for example. 31 | config.autoload_lib(ignore: %w[assets tasks]) 32 | 33 | # Configuration for the application, engines, and railties goes here. 34 | # 35 | # These settings can be overridden in specific environments using the files 36 | # in config/environments, which are processed later. 37 | # 38 | # config.time_zone = "Central Time (US & Canada)" 39 | # config.eager_load_paths << Rails.root.join("extras") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: storage/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: storage/test.sqlite3 22 | 23 | 24 | # SQLite3 write its data on the local filesystem, as such it requires 25 | # persistent disks. If you are deploying to a managed service, you should 26 | # make sure it provides disk persistence, as many don't. 27 | # 28 | # Similarly, if you deploy your application as a Docker container, you must 29 | # ensure the database is located in a persisted volume. 30 | production: 31 | <<: *default 32 | # database: path/to/persistent/storage/production.sqlite3 33 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Make code changes take effect immediately without server restart. 7 | config.enable_reloading = true 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable server timing. 16 | config.server_timing = true 17 | 18 | # Enable/disable Action Controller caching. By default Action Controller caching is disabled. 19 | # Run rails dev:cache to toggle Action Controller caching. 20 | if Rails.root.join("tmp/caching-dev.txt").exist? 21 | config.action_controller.perform_caching = true 22 | config.action_controller.enable_fragment_cache_logging = true 23 | config.public_file_server.headers = {"cache-control" => "public, max-age=#{2.days.to_i}"} 24 | else 25 | config.action_controller.perform_caching = false 26 | end 27 | 28 | # Change to :null_store to avoid any caching. 29 | config.cache_store = :memory_store 30 | 31 | # Print deprecation notices to the Rails logger. 32 | config.active_support.deprecation = :log 33 | 34 | # Raise an error on page load if there are pending migrations. 35 | config.active_record.migration_error = :page_load 36 | 37 | # Highlight code that triggered database queries in logs. 38 | config.active_record.verbose_query_logs = true 39 | 40 | # Append comments with runtime information tags to SQL queries in logs. 41 | config.active_record.query_log_tags_enabled = true 42 | 43 | # Highlight code that enqueued background job in logs. 44 | config.active_job.verbose_enqueue_logs = true 45 | 46 | # Raises error for missing translations. 47 | # config.i18n.raise_on_missing_translations = true 48 | 49 | # Annotate rendered view with file names. 50 | config.action_view.annotate_rendered_view_with_filenames = true 51 | 52 | # Raise error when a before_action's only/except options reference missing actions. 53 | config.action_controller.raise_on_missing_callback_actions = true 54 | end 55 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.enable_reloading = false 8 | 9 | # Eager load code on boot for better performance and memory savings (ignored by Rake tasks). 10 | config.eager_load = true 11 | 12 | # Full error reports are disabled. 13 | config.consider_all_requests_local = false 14 | 15 | # Turn on fragment caching in view templates. 16 | config.action_controller.perform_caching = true 17 | 18 | # Cache assets for far-future expiry since they are all digest stamped. 19 | config.public_file_server.headers = {"cache-control" => "public, max-age=#{1.year.to_i}"} 20 | 21 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 22 | # config.asset_host = "http://assets.example.com" 23 | 24 | # Assume all access to the app is happening through a SSL-terminating reverse proxy. 25 | config.assume_ssl = true 26 | 27 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 28 | config.force_ssl = true 29 | 30 | # Skip http-to-https redirect for the default health check endpoint. 31 | # config.ssl_options = { redirect: { exclude: ->(request) { request.path == "/up" } } } 32 | 33 | # Log to STDOUT with the current request id as a default log tag. 34 | config.log_tags = [:request_id] 35 | config.logger = ActiveSupport::TaggedLogging.logger($stdout) 36 | 37 | # Change to "debug" to log everything (including potentially personally-identifiable information!) 38 | config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info") 39 | 40 | # Prevent health checks from clogging up the logs. 41 | config.silence_healthcheck_path = "/up" 42 | 43 | # Don't log any deprecations. 44 | config.active_support.report_deprecations = false 45 | 46 | # Replace the default in-process memory cache store with a durable alternative. 47 | # config.cache_store = :mem_cache_store 48 | 49 | # Replace the default in-process and non-durable queuing backend for Active Job. 50 | # config.active_job.queue_adapter = :resque 51 | 52 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 53 | # the I18n.default_locale when a translation cannot be found). 54 | config.i18n.fallbacks = true 55 | 56 | # Do not dump schema after migrations. 57 | config.active_record.dump_schema_after_migration = false 58 | 59 | # Only use :id for inspections in production. 60 | config.active_record.attributes_for_inspect = [:id] 61 | 62 | # Enable DNS rebinding protection and other `Host` header attacks. 63 | # config.hosts = [ 64 | # "example.com", # Allow requests from example.com 65 | # /.*\.example\.com/ # Allow requests from subdomains like `www.example.com` 66 | # ] 67 | # 68 | # Skip DNS rebinding protection for the default health check endpoint. 69 | # config.host_authorization = { exclude: ->(request) { request.path == "/up" } } 70 | end 71 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # The test environment is used exclusively to run your application's 2 | # test suite. You never need to work with it otherwise. Remember that 3 | # your test database is "scratch space" for the test suite and is wiped 4 | # and recreated between test runs. Don't rely on the data there! 5 | 6 | Rails.application.configure do 7 | # Settings specified here will take precedence over those in config/application.rb. 8 | 9 | # While tests run files are not watched, reloading is not necessary. 10 | config.enable_reloading = false 11 | 12 | # Eager loading loads your entire application. When running a single test locally, 13 | # this is usually not necessary, and can slow down your test suite. However, it's 14 | # recommended that you enable it in continuous integration systems to ensure eager 15 | # loading is working properly before deploying your code. 16 | config.eager_load = ENV["CI"].present? 17 | 18 | # Configure public file server for tests with cache-control for performance. 19 | config.public_file_server.headers = {"cache-control" => "public, max-age=3600"} 20 | 21 | # Show full error reports. 22 | config.consider_all_requests_local = true 23 | config.cache_store = :null_store 24 | 25 | # Render exception templates for rescuable exceptions and raise for other exceptions. 26 | config.action_dispatch.show_exceptions = :rescuable 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | 31 | # Print deprecation notices to the stderr. 32 | config.active_support.deprecation = :stderr 33 | 34 | # Raises error for missing translations. 35 | # config.i18n.raise_on_missing_translations = true 36 | 37 | # Annotate rendered view with file names. 38 | # config.action_view.annotate_rendered_view_with_filenames = true 39 | 40 | # Raise error when a before_action's only/except options reference missing actions. 41 | config.action_controller.raise_on_missing_callback_actions = true 42 | end 43 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" if Rails.application.config.respond_to?(:assets) 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap, inline scripts, and inline styles. 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src style-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. 4 | # Use this to limit dissemination of sensitive information. 5 | # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc 8 | ] 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/granity.rb: -------------------------------------------------------------------------------- 1 | # Configure Granity for tests 2 | Granity.configure do |config| 3 | config.max_cache_size = 1000 4 | config.cache_ttl = 5.minutes 5 | config.enable_tracing = false 6 | config.max_traversal_depth = 5 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization and 2 | # are automatically loaded by Rails. If you want to use locales other than 3 | # English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more about the API, please read the Rails Internationalization guide 20 | # at https://guides.rubyonrails.org/i18n.html. 21 | # 22 | # Be aware that YAML interprets the following case-insensitive strings as 23 | # booleans: `true`, `false`, `on`, `off`, `yes`, `no`. Therefore, these strings 24 | # must be quoted to be interpreted as strings. For example: 25 | # 26 | # en: 27 | # "yes": yup 28 | # enabled: "ON" 29 | 30 | en: 31 | hello: "Hello world" 32 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # This configuration file will be evaluated by Puma. The top-level methods that 2 | # are invoked here are part of Puma's configuration DSL. For more information 3 | # about methods provided by the DSL, see https://puma.io/puma/Puma/DSL.html. 4 | # 5 | # Puma starts a configurable number of processes (workers) and each process 6 | # serves each request in a thread from an internal thread pool. 7 | # 8 | # You can control the number of workers using ENV["WEB_CONCURRENCY"]. You 9 | # should only set this value when you want to run 2 or more workers. The 10 | # default is already 1. 11 | # 12 | # The ideal number of threads per worker depends both on how much time the 13 | # application spends waiting for IO operations and on how much you wish to 14 | # prioritize throughput over latency. 15 | # 16 | # As a rule of thumb, increasing the number of threads will increase how much 17 | # traffic a given process can handle (throughput), but due to CRuby's 18 | # Global VM Lock (GVL) it has diminishing returns and will degrade the 19 | # response time (latency) of the application. 20 | # 21 | # The default is set to 3 threads as it's deemed a decent compromise between 22 | # throughput and latency for the average Rails application. 23 | # 24 | # Any libraries that use a connection pool or another resource pool should 25 | # be configured to provide at least as many connections as the number of 26 | # threads. This includes Active Record's `pool` parameter in `database.yml`. 27 | threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) 28 | threads threads_count, threads_count 29 | 30 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 31 | port ENV.fetch("PORT", 3000) 32 | 33 | # Allow puma to be restarted by `bin/rails restart` command. 34 | plugin :tmp_restart 35 | 36 | # Specify the PID file. Defaults to tmp/pids/server.pid in development. 37 | # In other environments, only set the PID file if requested. 38 | pidfile ENV["PIDFILE"] if ENV["PIDFILE"] 39 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount Granity::Engine => "/granity" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[8.0].define(version: 2025_03_17_000000) do 14 | create_table "granity_relation_tuples", force: :cascade do |t| 15 | t.string "object_type", null: false 16 | t.string "object_id", null: false 17 | t.string "relation", null: false 18 | t.string "subject_type", null: false 19 | t.string "subject_id", null: false 20 | t.datetime "created_at", null: false 21 | t.datetime "updated_at", null: false 22 | t.index ["object_type", "object_id", "relation", "subject_type", "subject_id"], name: "index_granity_tuples_unique", unique: true 23 | t.index ["object_type", "object_id", "relation"], name: "index_granity_tuples_on_object" 24 | t.index ["subject_type", "subject_id"], name: "index_granity_tuples_on_subject" 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy/public/400.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The server cannot process the request due to a client error (400 Bad Request) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The server cannot process the request due to a client error. Please check the request and try again. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The page you were looking for doesn’t exist (404 Not found) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The page you were looking for doesn’t exist. You may have mistyped the address or the page may have moved. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /spec/dummy/public/406-unsupported-browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your browser is not supported (406 Not Acceptable) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

Your browser is not supported.
Please upgrade your browser to continue.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | The change you wanted was rejected (422 Unprocessable Entity) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

The change you wanted was rejected. Maybe you tried to change something you didn’t have access to. If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | We’re sorry, but something went wrong (500 Internal Server Error) 8 | 9 | 10 | 11 | 12 | 13 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 |
104 |
105 | 106 |
107 |
108 |

We’re sorry, but something went wrong.
If you’re the application owner check the logs for more information.

109 |
110 |
111 | 112 | 113 | 114 | 115 | -------------------------------------------------------------------------------- /spec/dummy/public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yatish27/granity/cdc689615185eda86d181dfc82e0588a9119ebb9/spec/dummy/public/icon.png -------------------------------------------------------------------------------- /spec/dummy/public/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /spec/factories/examples.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :granity_example, class: "Granity::Example" do 3 | sequence(:name) { |n| "Example #{n}" } 4 | description { "A sample description" } 5 | active { true } 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/granity/authorization_engine_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe Granity::AuthorizationEngine do 4 | before(:all) do 5 | # Define GitHub-like schema 6 | Granity.define do 7 | resource_type :user do 8 | # Users don't have specific relations in this simple model 9 | end 10 | 11 | resource_type :organization do 12 | relation :member, type: :user 13 | relation :admin, type: :user 14 | relation :team, type: :team 15 | 16 | permission :view do 17 | include_relation :member 18 | include_relation :admin 19 | end 20 | 21 | permission :admin do 22 | include_relation :admin 23 | end 24 | end 25 | 26 | resource_type :team do 27 | relation :member, type: :user 28 | relation :maintainer, type: :user 29 | 30 | permission :view do 31 | include_relation :member 32 | include_relation :maintainer 33 | end 34 | 35 | permission :manage do 36 | include_relation :maintainer 37 | end 38 | end 39 | 40 | resource_type :repository do 41 | relation :owner, type: :user 42 | relation :collaborator, type: :user 43 | relation :organization, type: :organization 44 | 45 | permission :read do 46 | include_any do 47 | include_relation :owner 48 | include_relation :collaborator 49 | include_relation :member, from: :organization 50 | end 51 | end 52 | 53 | permission :write do 54 | include_any do 55 | include_relation :owner 56 | include_relation :collaborator 57 | end 58 | end 59 | 60 | permission :admin do 61 | include_relation :owner 62 | end 63 | end 64 | end 65 | end 66 | 67 | before(:each) do 68 | # Clear relation tuples and cache before each test 69 | Granity::RelationTuple.delete_all 70 | Granity::AuthorizationEngine.reset_cache 71 | end 72 | 73 | describe ".check_permission" do 74 | let(:user_alice) { {type: "user", id: "alice"} } 75 | let(:user_bob) { {type: "user", id: "bob"} } 76 | let(:user_charlie) { {type: "user", id: "charlie"} } 77 | 78 | let(:org_acme) { {type: "organization", id: "acme"} } 79 | let(:team_engineering) { {type: "team", id: "engineering"} } 80 | let(:repo_api) { {type: "repository", id: "api"} } 81 | 82 | context "direct relation permissions" do 83 | it "grants permission when subject has direct relation to resource" do 84 | # Make Alice the owner of the API repo 85 | Granity.create_relation( 86 | object_type: repo_api[:type], 87 | object_id: repo_api[:id], 88 | relation: "owner", 89 | subject_type: user_alice[:type], 90 | subject_id: user_alice[:id] 91 | ) 92 | 93 | # Alice should have read permission as owner 94 | expect( 95 | Granity.check_permission( 96 | subject_type: user_alice[:type], 97 | subject_id: user_alice[:id], 98 | permission: "read", 99 | resource_type: repo_api[:type], 100 | resource_id: repo_api[:id] 101 | ) 102 | ).to be true 103 | 104 | # Alice should have write permission as owner 105 | expect( 106 | Granity.check_permission( 107 | subject_type: user_alice[:type], 108 | subject_id: user_alice[:id], 109 | permission: "write", 110 | resource_type: repo_api[:type], 111 | resource_id: repo_api[:id] 112 | ) 113 | ).to be true 114 | 115 | # Alice should have admin permission as owner 116 | expect( 117 | Granity.check_permission( 118 | subject_type: user_alice[:type], 119 | subject_id: user_alice[:id], 120 | permission: "admin", 121 | resource_type: repo_api[:type], 122 | resource_id: repo_api[:id] 123 | ) 124 | ).to be true 125 | end 126 | 127 | it "denies permission when subject has no relation to resource" do 128 | # Bob has no relations to the API repo 129 | 130 | # Bob should not have read permission 131 | expect( 132 | Granity.check_permission( 133 | subject_type: user_bob[:type], 134 | subject_id: user_bob[:id], 135 | permission: "read", 136 | resource_type: repo_api[:type], 137 | resource_id: repo_api[:id] 138 | ) 139 | ).to be false 140 | end 141 | 142 | it "grants specific permissions based on relation" do 143 | # Make Bob a collaborator on the API repo 144 | Granity.create_relation( 145 | object_type: repo_api[:type], 146 | object_id: repo_api[:id], 147 | relation: "collaborator", 148 | subject_type: user_bob[:type], 149 | subject_id: user_bob[:id] 150 | ) 151 | 152 | # Bob should have read permission as collaborator 153 | expect( 154 | Granity.check_permission( 155 | subject_type: user_bob[:type], 156 | subject_id: user_bob[:id], 157 | permission: "read", 158 | resource_type: repo_api[:type], 159 | resource_id: repo_api[:id] 160 | ) 161 | ).to be true 162 | 163 | # Bob should have write permission as collaborator 164 | expect( 165 | Granity.check_permission( 166 | subject_type: user_bob[:type], 167 | subject_id: user_bob[:id], 168 | permission: "write", 169 | resource_type: repo_api[:type], 170 | resource_id: repo_api[:id] 171 | ) 172 | ).to be true 173 | 174 | # Bob should NOT have admin permission as collaborator 175 | expect( 176 | Granity.check_permission( 177 | subject_type: user_bob[:type], 178 | subject_id: user_bob[:id], 179 | permission: "admin", 180 | resource_type: repo_api[:type], 181 | resource_id: repo_api[:id] 182 | ) 183 | ).to be false 184 | end 185 | end 186 | 187 | context "indirect relation permissions" do 188 | it "grants permission through organization membership" do 189 | # Setup organization and repository relationship 190 | Granity.create_relation( 191 | object_type: repo_api[:type], 192 | object_id: repo_api[:id], 193 | relation: "organization", 194 | subject_type: org_acme[:type], 195 | subject_id: org_acme[:id] 196 | ) 197 | 198 | # Make Charlie a member of the Acme organization 199 | Granity.create_relation( 200 | object_type: org_acme[:type], 201 | object_id: org_acme[:id], 202 | relation: "member", 203 | subject_type: user_charlie[:type], 204 | subject_id: user_charlie[:id] 205 | ) 206 | 207 | # Charlie should have read permission through org membership 208 | expect( 209 | Granity.check_permission( 210 | subject_type: user_charlie[:type], 211 | subject_id: user_charlie[:id], 212 | permission: "read", 213 | resource_type: repo_api[:type], 214 | resource_id: repo_api[:id] 215 | ) 216 | ).to be true 217 | 218 | # Charlie should NOT have write permission through org membership 219 | expect( 220 | Granity.check_permission( 221 | subject_type: user_charlie[:type], 222 | subject_id: user_charlie[:id], 223 | permission: "write", 224 | resource_type: repo_api[:type], 225 | resource_id: repo_api[:id] 226 | ) 227 | ).to be false 228 | end 229 | end 230 | 231 | context "caching behavior" do 232 | it "caches permission check results" do 233 | # Create relations 234 | Granity.create_relation( 235 | object_type: repo_api[:type], 236 | object_id: repo_api[:id], 237 | relation: "owner", 238 | subject_type: user_alice[:type], 239 | subject_id: user_alice[:id] 240 | ) 241 | 242 | # Spy on the evaluate method to track real calls 243 | allow(Granity::PermissionEvaluator).to receive(:evaluate).and_call_original 244 | 245 | # First check should hit the database 246 | result1 = Granity.check_permission( 247 | subject_type: user_alice[:type], 248 | subject_id: user_alice[:id], 249 | permission: "read", 250 | resource_type: repo_api[:type], 251 | resource_id: repo_api[:id] 252 | ) 253 | 254 | # Second check should use cache 255 | result2 = Granity.check_permission( 256 | subject_type: user_alice[:type], 257 | subject_id: user_alice[:id], 258 | permission: "read", 259 | resource_type: repo_api[:type], 260 | resource_id: repo_api[:id] 261 | ) 262 | 263 | # Both results should be the same 264 | expect(result1).to eq(true) 265 | expect(result2).to eq(true) 266 | 267 | # PermissionEvaluator should only be called once 268 | expect(Granity::PermissionEvaluator).to have_received(:evaluate).once 269 | end 270 | 271 | it "invalidates cache when relations change" do 272 | # Check permission before any relations (should be false) 273 | initial_result = Granity.check_permission( 274 | subject_type: user_alice[:type], 275 | subject_id: user_alice[:id], 276 | permission: "read", 277 | resource_type: repo_api[:type], 278 | resource_id: repo_api[:id] 279 | ) 280 | 281 | # Create relation after checking 282 | Granity.create_relation( 283 | object_type: repo_api[:type], 284 | object_id: repo_api[:id], 285 | relation: "owner", 286 | subject_type: user_alice[:type], 287 | subject_id: user_alice[:id] 288 | ) 289 | 290 | # Check again - should get fresh result 291 | after_relation_result = Granity.check_permission( 292 | subject_type: user_alice[:type], 293 | subject_id: user_alice[:id], 294 | permission: "read", 295 | resource_type: repo_api[:type], 296 | resource_id: repo_api[:id] 297 | ) 298 | 299 | # Results should differ 300 | expect(initial_result).to eq(false) 301 | expect(after_relation_result).to eq(true) 302 | end 303 | end 304 | end 305 | end 306 | -------------------------------------------------------------------------------- /spec/granity/permission_evaluator_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe Granity::PermissionEvaluator do 4 | describe ".evaluate" do 5 | before(:all) do 6 | # Define schema for testing 7 | Granity.define do 8 | resource_type :document do 9 | relation :owner, type: :user 10 | relation :viewer, type: :user 11 | relation :editor, type: :user 12 | relation :organization, type: :organization 13 | 14 | permission :view do 15 | include_any do 16 | include_relation :owner 17 | include_relation :viewer 18 | include_relation :editor 19 | include_relation :member, from: :organization 20 | end 21 | end 22 | 23 | permission :edit do 24 | include_any do 25 | include_relation :owner 26 | include_relation :editor 27 | end 28 | end 29 | 30 | permission :manage do 31 | include_relation :owner 32 | end 33 | end 34 | 35 | resource_type :organization do 36 | relation :member, type: :user 37 | relation :admin, type: :user 38 | end 39 | end 40 | end 41 | 42 | before(:each) do 43 | # Clear relation tuples before each test 44 | Granity::RelationTuple.delete_all 45 | end 46 | 47 | context "simple relations" do 48 | let(:doc_id) { "123" } 49 | let(:user_id) { "456" } 50 | 51 | it "should return true when relation exists" do 52 | # Create owner relation 53 | Granity::RelationTuple.create!( 54 | object_type: "document", 55 | object_id: doc_id, 56 | relation: "owner", 57 | subject_type: "user", 58 | subject_id: user_id 59 | ) 60 | 61 | # Check permission 62 | result = described_class.evaluate( 63 | subject_type: "user", 64 | subject_id: user_id, 65 | permission: "view", 66 | resource_type: "document", 67 | resource_id: doc_id 68 | ) 69 | 70 | expect(result).to be true 71 | end 72 | 73 | it "should return false when relation does not exist" do 74 | # No relations created 75 | 76 | result = described_class.evaluate( 77 | subject_type: "user", 78 | subject_id: user_id, 79 | permission: "view", 80 | resource_type: "document", 81 | resource_id: doc_id 82 | ) 83 | 84 | expect(result).to be false 85 | end 86 | 87 | it "should handle different permissions based on relation" do 88 | # Create viewer relation 89 | Granity::RelationTuple.create!( 90 | object_type: "document", 91 | object_id: doc_id, 92 | relation: "viewer", 93 | subject_type: "user", 94 | subject_id: user_id 95 | ) 96 | 97 | # Should have view permission 98 | view_result = described_class.evaluate( 99 | subject_type: "user", 100 | subject_id: user_id, 101 | permission: "view", 102 | resource_type: "document", 103 | resource_id: doc_id 104 | ) 105 | 106 | # Should not have edit permission 107 | edit_result = described_class.evaluate( 108 | subject_type: "user", 109 | subject_id: user_id, 110 | permission: "edit", 111 | resource_type: "document", 112 | resource_id: doc_id 113 | ) 114 | 115 | expect(view_result).to be true 116 | expect(edit_result).to be false 117 | end 118 | end 119 | 120 | context "relation traversal (from clauses)" do 121 | let(:doc_id) { "123" } 122 | let(:org_id) { "789" } 123 | let(:user_id) { "456" } 124 | 125 | it "should handle relation traversal" do 126 | # Create organization relation to document 127 | Granity::RelationTuple.create!( 128 | object_type: "document", 129 | object_id: doc_id, 130 | relation: "organization", 131 | subject_type: "organization", 132 | subject_id: org_id 133 | ) 134 | 135 | # Create member relation to organization 136 | Granity::RelationTuple.create!( 137 | object_type: "organization", 138 | object_id: org_id, 139 | relation: "member", 140 | subject_type: "user", 141 | subject_id: user_id 142 | ) 143 | 144 | # Check permission 145 | result = described_class.evaluate( 146 | subject_type: "user", 147 | subject_id: user_id, 148 | permission: "view", 149 | resource_type: "document", 150 | resource_id: doc_id 151 | ) 152 | 153 | # User should have view permission through organization membership 154 | expect(result).to be true 155 | end 156 | 157 | it "should return false when traversal chain is broken" do 158 | # Create organization relation to document only 159 | Granity::RelationTuple.create!( 160 | object_type: "document", 161 | object_id: doc_id, 162 | relation: "organization", 163 | subject_type: "organization", 164 | subject_id: org_id 165 | ) 166 | 167 | # No member relation to organization 168 | 169 | # Check permission 170 | result = described_class.evaluate( 171 | subject_type: "user", 172 | subject_id: user_id, 173 | permission: "view", 174 | resource_type: "document", 175 | resource_id: doc_id 176 | ) 177 | 178 | # Should not have permission (chain is broken) 179 | expect(result).to be false 180 | end 181 | end 182 | end 183 | 184 | describe ".find_subjects" do 185 | before(:all) do 186 | # Define schema for testing 187 | Granity.define do 188 | resource_type :document do 189 | relation :owner, type: :user 190 | relation :viewer, type: :user 191 | 192 | permission :view do 193 | include_any do 194 | include_relation :owner 195 | include_relation :viewer 196 | end 197 | end 198 | end 199 | end 200 | end 201 | 202 | before(:each) do 203 | # Clear relation tuples before each test 204 | Granity::RelationTuple.delete_all 205 | end 206 | 207 | it "should return all subjects with a permission" do 208 | doc_id = "123" 209 | user1_id = "456" 210 | user2_id = "789" 211 | 212 | # Create relations 213 | Granity::RelationTuple.create!( 214 | object_type: "document", 215 | object_id: doc_id, 216 | relation: "owner", 217 | subject_type: "user", 218 | subject_id: user1_id 219 | ) 220 | 221 | Granity::RelationTuple.create!( 222 | object_type: "document", 223 | object_id: doc_id, 224 | relation: "viewer", 225 | subject_type: "user", 226 | subject_id: user2_id 227 | ) 228 | 229 | # Find subjects with view permission 230 | subjects = described_class.find_subjects( 231 | resource_type: "document", 232 | resource_id: doc_id, 233 | permission: "view" 234 | ) 235 | 236 | # Should find both users 237 | expect(subjects.size).to eq(2) 238 | expect(subjects).to include({type: "user", id: user1_id}) 239 | expect(subjects).to include({type: "user", id: user2_id}) 240 | end 241 | 242 | it "should return empty array when no subjects have permission" do 243 | doc_id = "123" 244 | 245 | # No relations created 246 | 247 | # Find subjects with view permission 248 | subjects = described_class.find_subjects( 249 | resource_type: "document", 250 | resource_id: doc_id, 251 | permission: "view" 252 | ) 253 | 254 | # Should return empty array 255 | expect(subjects).to be_empty 256 | end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /spec/granity/schema_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe Granity::Schema do 4 | describe ".define" do 5 | it "creates resource types with relations and permissions" do 6 | schema = Granity::Schema.define do 7 | resource_type :user do 8 | # Empty resource type 9 | end 10 | 11 | resource_type :document do 12 | relation :owner, type: :user 13 | relation :viewer, type: :user 14 | 15 | permission :view do 16 | include_relation :owner 17 | include_relation :viewer 18 | end 19 | 20 | permission :edit do 21 | include_relation :owner 22 | end 23 | end 24 | end 25 | 26 | # Check resource types 27 | expect(schema.resource_types.keys).to contain_exactly(:user, :document) 28 | 29 | # Check document resource type 30 | document = schema.resource_types[:document] 31 | expect(document.name).to eq(:document) 32 | 33 | # Check relations 34 | expect(document.relations.keys).to contain_exactly(:owner, :viewer) 35 | expect(document.relations[:owner].target_type).to eq(:user) 36 | 37 | # Check permissions 38 | expect(document.permissions.keys).to contain_exactly(:view, :edit) 39 | 40 | # Check view permission rules 41 | view_permission = document.permissions[:view] 42 | expect(view_permission.rules.size).to eq(2) 43 | expect(view_permission.rules[0]).to be_a(Granity::Rules::Relation) 44 | expect(view_permission.rules[0].relation).to eq(:owner) 45 | 46 | # Check edit permission rules 47 | edit_permission = document.permissions[:edit] 48 | expect(edit_permission.rules.size).to eq(1) 49 | expect(edit_permission.rules[0]).to be_a(Granity::Rules::Relation) 50 | expect(edit_permission.rules[0].relation).to eq(:owner) 51 | end 52 | 53 | it "creates complex permission rules with any/all blocks" do 54 | schema = Granity::Schema.define do 55 | resource_type :document do 56 | relation :owner, type: :user 57 | relation :editor, type: :user 58 | relation :viewer, type: :user 59 | 60 | permission :access do 61 | include_any do 62 | include_relation :owner 63 | 64 | include_all do 65 | include_relation :editor 66 | include_relation :viewer 67 | end 68 | end 69 | end 70 | end 71 | end 72 | 73 | # Get the access permission 74 | document = schema.resource_types[:document] 75 | access_permission = document.permissions[:access] 76 | 77 | # Check the structure - should be an ANY rule at the top 78 | expect(access_permission.rules.size).to eq(1) 79 | expect(access_permission.rules[0]).to be_a(Granity::Rules::Any) 80 | 81 | # Check the ANY rule 82 | any_rule = access_permission.rules[0] 83 | expect(any_rule.rules.size).to eq(2) 84 | expect(any_rule.rules[0]).to be_a(Granity::Rules::Relation) 85 | expect(any_rule.rules[1]).to be_a(Granity::Rules::All) 86 | 87 | # Check the ALL rule 88 | all_rule = any_rule.rules[1] 89 | expect(all_rule.rules.size).to eq(2) 90 | expect(all_rule.rules[0]).to be_a(Granity::Rules::Relation) 91 | expect(all_rule.rules[1]).to be_a(Granity::Rules::Relation) 92 | end 93 | 94 | it "creates permissions that include other permissions" do 95 | schema = Granity::Schema.define do 96 | resource_type :document do 97 | relation :owner, type: :user 98 | relation :viewer, type: :user 99 | 100 | permission :view do 101 | include_relation :viewer 102 | end 103 | 104 | permission :manage do 105 | include_permission :view 106 | include_relation :owner 107 | end 108 | end 109 | end 110 | 111 | # Get the permissions 112 | document = schema.resource_types[:document] 113 | manage_permission = document.permissions[:manage] 114 | 115 | # Check manage permission structure 116 | expect(manage_permission.rules.size).to eq(2) 117 | expect(manage_permission.rules[0]).to be_a(Granity::Rules::Permission) 118 | expect(manage_permission.rules[0].permission).to eq(:view) 119 | expect(manage_permission.rules[1]).to be_a(Granity::Rules::Relation) 120 | expect(manage_permission.rules[1].relation).to eq(:owner) 121 | end 122 | 123 | it "creates relations with from clauses" do 124 | schema = Granity::Schema.define do 125 | resource_type :document do 126 | relation :organization, type: :organization 127 | 128 | permission :view do 129 | include_relation :member, from: :organization 130 | end 131 | end 132 | 133 | resource_type :organization do 134 | relation :member, type: :user 135 | end 136 | end 137 | 138 | # Get the permission 139 | document = schema.resource_types[:document] 140 | view_permission = document.permissions[:view] 141 | 142 | # Check the rule structure 143 | expect(view_permission.rules.size).to eq(1) 144 | expect(view_permission.rules[0]).to be_a(Granity::Rules::Relation) 145 | 146 | # Check the from clause 147 | relation_rule = view_permission.rules[0] 148 | expect(relation_rule.relation).to eq(:member) 149 | expect(relation_rule.from).to eq(:organization) 150 | end 151 | end 152 | 153 | describe ".current" do 154 | it "returns the last defined schema" do 155 | # Define a schema 156 | Granity::Schema.define do 157 | resource_type :document do 158 | relation :owner, type: :user 159 | end 160 | end 161 | 162 | # Define another schema 163 | schema2 = Granity::Schema.define do 164 | resource_type :user do 165 | # Empty 166 | end 167 | end 168 | 169 | # Current should be the last one defined 170 | expect(Granity::Schema.current).to eq(schema2) 171 | expect(Granity::Schema.current.resource_types.keys).to contain_exactly(:user) 172 | end 173 | end 174 | end 175 | -------------------------------------------------------------------------------- /spec/integration/scenarios/drive_authorization_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Google Drive Authorization" do 4 | before(:all) do 5 | # Define Google Drive schema 6 | Granity.define do 7 | resource_type :user do 8 | # Users don't have specific relations 9 | end 10 | 11 | resource_type :group do 12 | relation :member, type: :user 13 | end 14 | 15 | resource_type :folder do 16 | relation :owner, type: :user 17 | relation :parent, type: :folder 18 | relation :viewer, type: :user 19 | relation :viewer_group, type: :group # For groups who can view 20 | 21 | permission :view do 22 | include_any do 23 | include_relation :owner 24 | include_relation :viewer 25 | include_relation :member, from: :viewer_group 26 | include_permission :view, from: :parent 27 | end 28 | end 29 | 30 | permission :create_file do 31 | include_relation :owner 32 | end 33 | end 34 | 35 | resource_type :document do 36 | relation :owner, type: :user 37 | relation :parent, type: :folder 38 | relation :viewer, type: :user 39 | relation :viewer_group, type: :group # For groups who can view 40 | 41 | permission :view do 42 | include_any do 43 | include_relation :owner 44 | include_relation :viewer 45 | include_relation :member, from: :viewer_group 46 | include_permission :view, from: :parent 47 | end 48 | end 49 | 50 | permission :write do 51 | include_any do 52 | include_relation :owner 53 | include_permission :create_file, from: :parent 54 | end 55 | end 56 | 57 | permission :change_owner do 58 | include_relation :owner 59 | end 60 | 61 | permission :share do 62 | include_any do 63 | include_relation :owner 64 | include_permission :create_file, from: :parent 65 | end 66 | end 67 | end 68 | end 69 | end 70 | 71 | before(:each) do 72 | Granity::AuthorizationEngine.reset_cache 73 | end 74 | 75 | describe "Google Drive scenario" do 76 | # Define users 77 | let(:anne) { {type: "user", id: "anne"} } 78 | let(:beth) { {type: "user", id: "beth"} } 79 | let(:charles) { {type: "user", id: "charles"} } 80 | let(:daniel) { {type: "user", id: "daniel"} } 81 | 82 | # Define groups 83 | let(:contoso) { {type: "group", id: "contoso"} } 84 | let(:fabrikam) { {type: "group", id: "fabrikam"} } 85 | 86 | # Define folders and documents 87 | let(:product_folder) { {type: "folder", id: "product_2021"} } 88 | let(:public_roadmap) { {type: "document", id: "public_roadmap"} } 89 | let(:roadmap_2021) { {type: "document", id: "roadmap_2021"} } 90 | 91 | before(:each) do 92 | # Set up the scenario 93 | 94 | # Group memberships 95 | Granity.create_relation( 96 | object_type: contoso[:type], 97 | object_id: contoso[:id], 98 | relation: "member", 99 | subject_type: anne[:type], 100 | subject_id: anne[:id] 101 | ) 102 | 103 | Granity.create_relation( 104 | object_type: contoso[:type], 105 | object_id: contoso[:id], 106 | relation: "member", 107 | subject_type: beth[:type], 108 | subject_id: beth[:id] 109 | ) 110 | 111 | Granity.create_relation( 112 | object_type: fabrikam[:type], 113 | object_id: fabrikam[:id], 114 | relation: "member", 115 | subject_type: charles[:type], 116 | subject_id: charles[:id] 117 | ) 118 | 119 | # Folder structure 120 | Granity.create_relation( 121 | object_type: product_folder[:type], 122 | object_id: product_folder[:id], 123 | relation: "owner", 124 | subject_type: anne[:type], 125 | subject_id: anne[:id] 126 | ) 127 | 128 | Granity.create_relation( 129 | object_type: product_folder[:type], 130 | object_id: product_folder[:id], 131 | relation: "viewer_group", 132 | subject_type: fabrikam[:type], 133 | subject_id: fabrikam[:id] 134 | ) 135 | 136 | # Documents and their parents 137 | Granity.create_relation( 138 | object_type: public_roadmap[:type], 139 | object_id: public_roadmap[:id], 140 | relation: "parent", 141 | subject_type: product_folder[:type], 142 | subject_id: product_folder[:id] 143 | ) 144 | 145 | Granity.create_relation( 146 | object_type: roadmap_2021[:type], 147 | object_id: roadmap_2021[:id], 148 | relation: "parent", 149 | subject_type: product_folder[:type], 150 | subject_id: product_folder[:id] 151 | ) 152 | 153 | # Document permissions 154 | Granity.create_relation( 155 | object_type: roadmap_2021[:type], 156 | object_id: roadmap_2021[:id], 157 | relation: "viewer", 158 | subject_type: beth[:type], 159 | subject_id: beth[:id] 160 | ) 161 | 162 | # Make public roadmap viewable by everyone 163 | # For simplicity, we'll add each user directly 164 | [anne, beth, charles, daniel].each do |user| 165 | Granity.create_relation( 166 | object_type: public_roadmap[:type], 167 | object_id: public_roadmap[:id], 168 | relation: "viewer", 169 | subject_type: user[:type], 170 | subject_id: user[:id] 171 | ) 172 | end 173 | end 174 | 175 | context "2021 Roadmap document" do 176 | it "allows Anne to write" do 177 | expect( 178 | Granity.check_permission( 179 | subject_type: anne[:type], 180 | subject_id: anne[:id], 181 | permission: "write", 182 | resource_type: roadmap_2021[:type], 183 | resource_id: roadmap_2021[:id] 184 | ) 185 | ).to be true 186 | end 187 | 188 | it "does not allow Beth to change owner" do 189 | expect( 190 | Granity.check_permission( 191 | subject_type: beth[:type], 192 | subject_id: beth[:id], 193 | permission: "change_owner", 194 | resource_type: roadmap_2021[:type], 195 | resource_id: roadmap_2021[:id] 196 | ) 197 | ).to be false 198 | end 199 | 200 | it "allows Charles to read (view)" do 201 | expect( 202 | Granity.check_permission( 203 | subject_type: charles[:type], 204 | subject_id: charles[:id], 205 | permission: "view", 206 | resource_type: roadmap_2021[:type], 207 | resource_id: roadmap_2021[:id] 208 | ) 209 | ).to be true 210 | end 211 | 212 | it "does not allow Charles to write" do 213 | expect( 214 | Granity.check_permission( 215 | subject_type: charles[:type], 216 | subject_id: charles[:id], 217 | permission: "write", 218 | resource_type: roadmap_2021[:type], 219 | resource_id: roadmap_2021[:id] 220 | ) 221 | ).to be false 222 | end 223 | 224 | it "does not allow Daniel to read" do 225 | expect( 226 | Granity.check_permission( 227 | subject_type: daniel[:type], 228 | subject_id: daniel[:id], 229 | permission: "view", 230 | resource_type: roadmap_2021[:type], 231 | resource_id: roadmap_2021[:id] 232 | ) 233 | ).to be false 234 | end 235 | end 236 | 237 | context "Public Roadmap document" do 238 | it "allows Daniel to read" do 239 | expect( 240 | Granity.check_permission( 241 | subject_type: daniel[:type], 242 | subject_id: daniel[:id], 243 | permission: "view", 244 | resource_type: public_roadmap[:type], 245 | resource_id: public_roadmap[:id] 246 | ) 247 | ).to be true 248 | end 249 | 250 | it "allows Anne to write" do 251 | expect( 252 | Granity.check_permission( 253 | subject_type: anne[:type], 254 | subject_id: anne[:id], 255 | permission: "write", 256 | resource_type: public_roadmap[:type], 257 | resource_id: public_roadmap[:id] 258 | ) 259 | ).to be true 260 | end 261 | 262 | it "does not allow Charles to write" do 263 | expect( 264 | Granity.check_permission( 265 | subject_type: charles[:type], 266 | subject_id: charles[:id], 267 | permission: "write", 268 | resource_type: public_roadmap[:type], 269 | resource_id: public_roadmap[:id] 270 | ) 271 | ).to be false 272 | end 273 | end 274 | end 275 | end 276 | -------------------------------------------------------------------------------- /spec/integration/scenarios/github_authorization_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "GitHub Authorization" do 4 | before(:all) do 5 | # Define GitHub schema 6 | Granity.define do 7 | resource_type :user do 8 | # Users don't have specific relations 9 | end 10 | 11 | resource_type :organization do 12 | relation :member, type: :user 13 | relation :admin, type: :user 14 | relation :team, type: :team 15 | 16 | permission :view do 17 | include_relation :member 18 | include_relation :admin 19 | end 20 | 21 | permission :admin do 22 | include_relation :admin 23 | end 24 | end 25 | 26 | resource_type :team do 27 | relation :member, type: :user 28 | relation :maintainer, type: :user 29 | relation :organization, type: :organization 30 | 31 | permission :view do 32 | include_relation :member 33 | include_relation :maintainer 34 | end 35 | 36 | permission :manage do 37 | include_relation :maintainer 38 | end 39 | end 40 | 41 | resource_type :repository do 42 | relation :owner_user, type: :user 43 | relation :owner_org, type: :organization 44 | relation :collaborator, type: :user 45 | relation :reader_team, type: :team 46 | relation :writer_team, type: :team 47 | relation :admin_team, type: :team 48 | 49 | # Read access (view code, clone, pull) 50 | permission :read do 51 | include_any do 52 | include_relation :owner_user 53 | include_relation :collaborator 54 | include_relation :member, from: :reader_team 55 | include_relation :member, from: :writer_team 56 | include_relation :member, from: :admin_team 57 | include_permission :read_org_repos, from: :owner_org 58 | end 59 | end 60 | 61 | # Write access (push, create branches) 62 | permission :write do 63 | include_any do 64 | include_relation :owner_user 65 | include_relation :collaborator 66 | include_relation :member, from: :writer_team 67 | include_relation :member, from: :admin_team 68 | include_permission :write_org_repos, from: :owner_org 69 | end 70 | end 71 | 72 | # Admin access (settings, delete, transfer) 73 | permission :admin do 74 | include_any do 75 | include_relation :owner_user 76 | include_relation :member, from: :admin_team 77 | include_permission :admin_org_repos, from: :owner_org 78 | end 79 | end 80 | end 81 | 82 | # Organization permissions regarding repos 83 | resource_type :organization do 84 | # Can read all org repos 85 | permission :read_org_repos do 86 | include_relation :member 87 | include_relation :admin 88 | end 89 | 90 | # Can write to org repos if admin or explicitly granted 91 | permission :write_org_repos do 92 | include_relation :admin 93 | end 94 | 95 | # Can admin org repos 96 | permission :admin_org_repos do 97 | include_relation :admin 98 | end 99 | end 100 | end 101 | end 102 | 103 | before(:each) do 104 | Granity::AuthorizationEngine.reset_cache 105 | end 106 | 107 | describe "GitHub scenario" do 108 | # Define users 109 | let(:alice) { {type: "user", id: "alice"} } 110 | let(:bob) { {type: "user", id: "bob"} } 111 | let(:charlie) { {type: "user", id: "charlie"} } 112 | let(:diana) { {type: "user", id: "diana"} } 113 | 114 | # Define organizations 115 | let(:acme_org) { {type: "organization", id: "acme"} } 116 | 117 | # Define teams 118 | let(:engineering_team) { {type: "team", id: "engineering"} } 119 | let(:devops_team) { {type: "team", id: "devops"} } 120 | 121 | # Define repositories 122 | let(:personal_repo) { {type: "repository", id: "alice/personal"} } 123 | let(:api_repo) { {type: "repository", id: "acme/api"} } 124 | let(:docs_repo) { {type: "repository", id: "acme/docs"} } 125 | 126 | before(:each) do 127 | # Set up the scenario 128 | 129 | # Organization memberships 130 | # Alice is an admin of Acme org 131 | Granity.create_relation( 132 | object_type: acme_org[:type], 133 | object_id: acme_org[:id], 134 | relation: "admin", 135 | subject_type: alice[:type], 136 | subject_id: alice[:id] 137 | ) 138 | 139 | # Bob is a member of Acme org 140 | Granity.create_relation( 141 | object_type: acme_org[:type], 142 | object_id: acme_org[:id], 143 | relation: "member", 144 | subject_type: bob[:type], 145 | subject_id: bob[:id] 146 | ) 147 | 148 | # Charlie is a member of Acme org 149 | Granity.create_relation( 150 | object_type: acme_org[:type], 151 | object_id: acme_org[:id], 152 | relation: "member", 153 | subject_type: charlie[:type], 154 | subject_id: charlie[:id] 155 | ) 156 | 157 | # Team memberships 158 | # Bob is in engineering team 159 | Granity.create_relation( 160 | object_type: engineering_team[:type], 161 | object_id: engineering_team[:id], 162 | relation: "member", 163 | subject_type: bob[:type], 164 | subject_id: bob[:id] 165 | ) 166 | 167 | # Charlie is a maintainer of the engineering team 168 | Granity.create_relation( 169 | object_type: engineering_team[:type], 170 | object_id: engineering_team[:id], 171 | relation: "maintainer", 172 | subject_type: charlie[:type], 173 | subject_id: charlie[:id] 174 | ) 175 | 176 | # Charlie is in devops team 177 | Granity.create_relation( 178 | object_type: devops_team[:type], 179 | object_id: devops_team[:id], 180 | relation: "member", 181 | subject_type: charlie[:type], 182 | subject_id: charlie[:id] 183 | ) 184 | 185 | # Team to org relationship 186 | Granity.create_relation( 187 | object_type: engineering_team[:type], 188 | object_id: engineering_team[:id], 189 | relation: "organization", 190 | subject_type: acme_org[:type], 191 | subject_id: acme_org[:id] 192 | ) 193 | 194 | Granity.create_relation( 195 | object_type: devops_team[:type], 196 | object_id: devops_team[:id], 197 | relation: "organization", 198 | subject_type: acme_org[:type], 199 | subject_id: acme_org[:id] 200 | ) 201 | 202 | # Repository ownership 203 | # Alice owns her personal repo 204 | Granity.create_relation( 205 | object_type: personal_repo[:type], 206 | object_id: personal_repo[:id], 207 | relation: "owner_user", 208 | subject_type: alice[:type], 209 | subject_id: alice[:id] 210 | ) 211 | 212 | # Acme org owns the API and Docs repos 213 | Granity.create_relation( 214 | object_type: api_repo[:type], 215 | object_id: api_repo[:id], 216 | relation: "owner_org", 217 | subject_type: acme_org[:type], 218 | subject_id: acme_org[:id] 219 | ) 220 | 221 | Granity.create_relation( 222 | object_type: docs_repo[:type], 223 | object_id: docs_repo[:id], 224 | relation: "owner_org", 225 | subject_type: acme_org[:type], 226 | subject_id: acme_org[:id] 227 | ) 228 | 229 | # Repository team access 230 | # Engineering team has write access to API repo 231 | Granity.create_relation( 232 | object_type: api_repo[:type], 233 | object_id: api_repo[:id], 234 | relation: "writer_team", 235 | subject_type: engineering_team[:type], 236 | subject_id: engineering_team[:id] 237 | ) 238 | 239 | # DevOps team has admin access to API repo 240 | Granity.create_relation( 241 | object_type: api_repo[:type], 242 | object_id: api_repo[:id], 243 | relation: "admin_team", 244 | subject_type: devops_team[:type], 245 | subject_id: devops_team[:id] 246 | ) 247 | 248 | # Engineering team has read access to Docs repo 249 | Granity.create_relation( 250 | object_type: docs_repo[:type], 251 | object_id: docs_repo[:id], 252 | relation: "reader_team", 253 | subject_type: engineering_team[:type], 254 | subject_id: engineering_team[:id] 255 | ) 256 | 257 | # Direct collaborator access 258 | # Diana is a collaborator on Alice's personal repo 259 | Granity.create_relation( 260 | object_type: personal_repo[:type], 261 | object_id: personal_repo[:id], 262 | relation: "collaborator", 263 | subject_type: diana[:type], 264 | subject_id: diana[:id] 265 | ) 266 | end 267 | 268 | context "Alice's personal repository" do 269 | it "allows Alice to admin her own repo" do 270 | expect( 271 | Granity.check_permission( 272 | subject_type: alice[:type], 273 | subject_id: alice[:id], 274 | permission: "admin", 275 | resource_type: personal_repo[:type], 276 | resource_id: personal_repo[:id] 277 | ) 278 | ).to be true 279 | end 280 | 281 | it "allows Diana to write as a collaborator" do 282 | expect( 283 | Granity.check_permission( 284 | subject_type: diana[:type], 285 | subject_id: diana[:id], 286 | permission: "write", 287 | resource_type: personal_repo[:type], 288 | resource_id: personal_repo[:id] 289 | ) 290 | ).to be true 291 | end 292 | 293 | it "does not allow Diana to admin the repo" do 294 | expect( 295 | Granity.check_permission( 296 | subject_type: diana[:type], 297 | subject_id: diana[:id], 298 | permission: "admin", 299 | resource_type: personal_repo[:type], 300 | resource_id: personal_repo[:id] 301 | ) 302 | ).to be false 303 | end 304 | 305 | it "does not allow Bob to read the repo" do 306 | expect( 307 | Granity.check_permission( 308 | subject_type: bob[:type], 309 | subject_id: bob[:id], 310 | permission: "read", 311 | resource_type: personal_repo[:type], 312 | resource_id: personal_repo[:id] 313 | ) 314 | ).to be false 315 | end 316 | end 317 | 318 | context "Acme organization's API repository" do 319 | it "allows Alice to admin as an org admin" do 320 | expect( 321 | Granity.check_permission( 322 | subject_type: alice[:type], 323 | subject_id: alice[:id], 324 | permission: "admin", 325 | resource_type: api_repo[:type], 326 | resource_id: api_repo[:id] 327 | ) 328 | ).to be true 329 | end 330 | 331 | it "allows Bob to write as engineering team member" do 332 | expect( 333 | Granity.check_permission( 334 | subject_type: bob[:type], 335 | subject_id: bob[:id], 336 | permission: "write", 337 | resource_type: api_repo[:type], 338 | resource_id: api_repo[:id] 339 | ) 340 | ).to be true 341 | end 342 | 343 | it "allows Charlie to admin as devops team member" do 344 | expect( 345 | Granity.check_permission( 346 | subject_type: charlie[:type], 347 | subject_id: charlie[:id], 348 | permission: "admin", 349 | resource_type: api_repo[:type], 350 | resource_id: api_repo[:id] 351 | ) 352 | ).to be true 353 | end 354 | 355 | it "does not allow Diana to read the repo" do 356 | expect( 357 | Granity.check_permission( 358 | subject_type: diana[:type], 359 | subject_id: diana[:id], 360 | permission: "read", 361 | resource_type: api_repo[:type], 362 | resource_id: api_repo[:id] 363 | ) 364 | ).to be false 365 | end 366 | end 367 | 368 | context "Acme organization's Docs repository" do 369 | it "allows Bob to read but not write as engineering team member" do 370 | # Can read 371 | expect( 372 | Granity.check_permission( 373 | subject_type: bob[:type], 374 | subject_id: bob[:id], 375 | permission: "read", 376 | resource_type: docs_repo[:type], 377 | resource_id: docs_repo[:id] 378 | ) 379 | ).to be true 380 | 381 | # Cannot write 382 | expect( 383 | Granity.check_permission( 384 | subject_type: bob[:type], 385 | subject_id: bob[:id], 386 | permission: "write", 387 | resource_type: docs_repo[:type], 388 | resource_id: docs_repo[:id] 389 | ) 390 | ).to be false 391 | end 392 | 393 | it "allows Charlie to read docs repo but not admin it" do 394 | # Charlie is in engineering (read access) and devops (which has no access to docs) 395 | # Can read 396 | expect( 397 | Granity.check_permission( 398 | subject_type: charlie[:type], 399 | subject_id: charlie[:id], 400 | permission: "read", 401 | resource_type: docs_repo[:type], 402 | resource_id: docs_repo[:id] 403 | ) 404 | ).to be true 405 | 406 | # Cannot admin 407 | expect( 408 | Granity.check_permission( 409 | subject_type: charlie[:type], 410 | subject_id: charlie[:id], 411 | permission: "admin", 412 | resource_type: docs_repo[:type], 413 | resource_id: docs_repo[:id] 414 | ) 415 | ).to be false 416 | end 417 | end 418 | end 419 | end 420 | -------------------------------------------------------------------------------- /spec/integration/scenarios/project_management_authorization_spec.rb: -------------------------------------------------------------------------------- 1 | require "rails_helper" 2 | 3 | RSpec.describe "Project Management Authorization" do 4 | before(:all) do 5 | # Define project management schema 6 | Granity.define do 7 | # User resource type 8 | resource_type :user do 9 | # Users don't have specific relations in this model 10 | end 11 | 12 | # Project resource type 13 | resource_type :project do 14 | # Relations for users 15 | relation :admin, type: :user 16 | relation :editor, type: :user 17 | relation :commenter, type: :user 18 | 19 | # Relations for teams 20 | relation :admin_team, type: :team 21 | relation :editor_team, type: :team 22 | relation :commenter_team, type: :team 23 | 24 | # Permission to check if a user can edit tasks in this project 25 | permission :can_edit_tasks do 26 | include_any do 27 | # Check if user is admin 28 | include_relation :admin 29 | 30 | # Check if user is editor 31 | include_relation :editor 32 | 33 | # Check if user is member of admin team 34 | include_relation :member, from: :admin_team 35 | 36 | # Check if user is member of editor team 37 | include_relation :member, from: :editor_team 38 | end 39 | end 40 | end 41 | 42 | # Team resource type 43 | resource_type :team do 44 | # Relation for users 45 | relation :member, type: :user 46 | end 47 | 48 | # Task resource type 49 | resource_type :task do 50 | # Relations for projects 51 | relation :belongs_to, type: :project 52 | 53 | # Relation for creator 54 | relation :creator, type: :user 55 | 56 | # Permission to edit a task 57 | permission :edit do 58 | include_any do 59 | # Creator can edit 60 | include_relation :creator 61 | 62 | # Project admins and editors can edit 63 | include_permission :can_edit_tasks, from: :belongs_to 64 | end 65 | end 66 | end 67 | end 68 | end 69 | 70 | before(:each) do 71 | # Clear relation tuples and cache before each test 72 | Granity::RelationTuple.delete_all 73 | Granity::AuthorizationEngine.reset_cache 74 | end 75 | 76 | describe "Task Edit Permission" do 77 | let(:user1_id) { "user1" } 78 | let(:user2_id) { "user2" } 79 | let(:user3_id) { "user3" } 80 | let(:project_id) { "project1" } 81 | let(:team1_id) { "team1" } 82 | let(:team2_id) { "team2" } 83 | let(:task_id) { "task1" } 84 | 85 | it "allows task creator to edit the task" do 86 | # Create relation: user1 is the creator of task1 87 | Granity::RelationTuple.create!( 88 | object_type: "task", 89 | object_id: task_id, 90 | relation: "creator", 91 | subject_type: "user", 92 | subject_id: user1_id 93 | ) 94 | 95 | # Check if user1 can edit task1 96 | result = Granity.check_permission( 97 | subject_type: "user", 98 | subject_id: user1_id, 99 | permission: "edit", 100 | resource_type: "task", 101 | resource_id: task_id 102 | ) 103 | 104 | expect(result).to be true 105 | end 106 | 107 | it "allows project admin to edit tasks in the project" do 108 | # Create relation: task1 belongs to project1 109 | Granity::RelationTuple.create!( 110 | object_type: "task", 111 | object_id: task_id, 112 | relation: "belongs_to", 113 | subject_type: "project", 114 | subject_id: project_id 115 | ) 116 | 117 | # Create relation: user2 is admin of project1 118 | Granity::RelationTuple.create!( 119 | object_type: "project", 120 | object_id: project_id, 121 | relation: "admin", 122 | subject_type: "user", 123 | subject_id: user2_id 124 | ) 125 | 126 | # Check if user2 can edit task1 127 | result = Granity.check_permission( 128 | subject_type: "user", 129 | subject_id: user2_id, 130 | permission: "edit", 131 | resource_type: "task", 132 | resource_id: task_id 133 | ) 134 | 135 | expect(result).to be true 136 | end 137 | 138 | it "allows project editor to edit tasks in the project" do 139 | # Create relation: task1 belongs to project1 140 | Granity::RelationTuple.create!( 141 | object_type: "task", 142 | object_id: task_id, 143 | relation: "belongs_to", 144 | subject_type: "project", 145 | subject_id: project_id 146 | ) 147 | 148 | # Create relation: user3 is editor of project1 149 | Granity::RelationTuple.create!( 150 | object_type: "project", 151 | object_id: project_id, 152 | relation: "editor", 153 | subject_type: "user", 154 | subject_id: user3_id 155 | ) 156 | 157 | # Check if user3 can edit task1 158 | result = Granity.check_permission( 159 | subject_type: "user", 160 | subject_id: user3_id, 161 | permission: "edit", 162 | resource_type: "task", 163 | resource_id: task_id 164 | ) 165 | 166 | expect(result).to be true 167 | end 168 | 169 | it "allows team members to edit tasks if their team has admin role on the project" do 170 | # Create relation: task1 belongs to project1 171 | Granity::RelationTuple.create!( 172 | object_type: "task", 173 | object_id: task_id, 174 | relation: "belongs_to", 175 | subject_type: "project", 176 | subject_id: project_id 177 | ) 178 | 179 | # Create relation: team1 is admin_team of project1 180 | Granity::RelationTuple.create!( 181 | object_type: "project", 182 | object_id: project_id, 183 | relation: "admin_team", 184 | subject_type: "team", 185 | subject_id: team1_id 186 | ) 187 | 188 | # Create relation: user1 is member of team1 189 | Granity::RelationTuple.create!( 190 | object_type: "team", 191 | object_id: team1_id, 192 | relation: "member", 193 | subject_type: "user", 194 | subject_id: user1_id 195 | ) 196 | 197 | # Check if user1 can edit task1 through team membership 198 | result = Granity.check_permission( 199 | subject_type: "user", 200 | subject_id: user1_id, 201 | permission: "edit", 202 | resource_type: "task", 203 | resource_id: task_id 204 | ) 205 | 206 | expect(result).to be true 207 | end 208 | 209 | it "allows team members to edit tasks if their team has editor role on the project" do 210 | # Create relation: task1 belongs to project1 211 | Granity::RelationTuple.create!( 212 | object_type: "task", 213 | object_id: task_id, 214 | relation: "belongs_to", 215 | subject_type: "project", 216 | subject_id: project_id 217 | ) 218 | 219 | # Create relation: team2 is editor_team of project1 220 | Granity::RelationTuple.create!( 221 | object_type: "project", 222 | object_id: project_id, 223 | relation: "editor_team", 224 | subject_type: "team", 225 | subject_id: team2_id 226 | ) 227 | 228 | # Create relation: user2 is member of team2 229 | Granity::RelationTuple.create!( 230 | object_type: "team", 231 | object_id: team2_id, 232 | relation: "member", 233 | subject_type: "user", 234 | subject_id: user2_id 235 | ) 236 | 237 | # Check if user2 can edit task1 through team membership 238 | result = Granity.check_permission( 239 | subject_type: "user", 240 | subject_id: user2_id, 241 | permission: "edit", 242 | resource_type: "task", 243 | resource_id: task_id 244 | ) 245 | 246 | expect(result).to be true 247 | end 248 | 249 | it "denies edit permission to users without appropriate roles" do 250 | # Create relation: task1 belongs to project1 251 | Granity::RelationTuple.create!( 252 | object_type: "task", 253 | object_id: task_id, 254 | relation: "belongs_to", 255 | subject_type: "project", 256 | subject_id: project_id 257 | ) 258 | 259 | # Create relation: user3 is commenter of project1 (not enough for edit) 260 | Granity::RelationTuple.create!( 261 | object_type: "project", 262 | object_id: project_id, 263 | relation: "commenter", 264 | subject_type: "user", 265 | subject_id: user3_id 266 | ) 267 | 268 | # Check if user3 can edit task1 (should be denied) 269 | result = Granity.check_permission( 270 | subject_type: "user", 271 | subject_id: user3_id, 272 | permission: "edit", 273 | resource_type: "task", 274 | resource_id: task_id 275 | ) 276 | 277 | expect(result).to be false 278 | end 279 | 280 | it "finds all users who can edit a task" do 281 | # Create relation: task1 belongs to project1 282 | Granity::RelationTuple.create!( 283 | object_type: "task", 284 | object_id: task_id, 285 | relation: "belongs_to", 286 | subject_type: "project", 287 | subject_id: project_id 288 | ) 289 | 290 | # Create relation: user1 is the creator of task1 291 | Granity::RelationTuple.create!( 292 | object_type: "task", 293 | object_id: task_id, 294 | relation: "creator", 295 | subject_type: "user", 296 | subject_id: user1_id 297 | ) 298 | 299 | # Create relation: user2 is admin of project1 300 | Granity::RelationTuple.create!( 301 | object_type: "project", 302 | object_id: project_id, 303 | relation: "admin", 304 | subject_type: "user", 305 | subject_id: user2_id 306 | ) 307 | 308 | # Create relation: team1 is admin_team of project1 309 | Granity::RelationTuple.create!( 310 | object_type: "project", 311 | object_id: project_id, 312 | relation: "admin_team", 313 | subject_type: "team", 314 | subject_id: team1_id 315 | ) 316 | 317 | # Create relation: user3 is member of team1 318 | Granity::RelationTuple.create!( 319 | object_type: "team", 320 | object_id: team1_id, 321 | relation: "member", 322 | subject_type: "user", 323 | subject_id: user3_id 324 | ) 325 | 326 | # Find all users who can edit task1 327 | editors = Granity.find_subjects( 328 | resource_type: "task", 329 | resource_id: task_id, 330 | permission: "edit" 331 | ) 332 | 333 | # Should find all three users 334 | expect(editors.size).to eq(3) 335 | expect(editors).to include({type: "user", id: user1_id}) 336 | expect(editors).to include({type: "user", id: user2_id}) 337 | expect(editors).to include({type: "user", id: user3_id}) 338 | end 339 | end 340 | end 341 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV["RAILS_ENV"] ||= "test" 3 | require_relative "../spec/dummy/config/environment" 4 | # Prevent database truncation if the environment is production 5 | abort("The Rails environment is running in production mode!") if Rails.env.production? 6 | require "rspec/rails" 7 | # Add additional requires below this line. Rails is not loaded until this point! 8 | require "factory_bot_rails" 9 | require "database_cleaner/active_record" 10 | 11 | # Initialize Granity configuration for tests 12 | # Move the Granity configuration after Rails is fully loaded to avoid frozen array errors 13 | # Granity configuration moved to spec/dummy/config/initializers/granity.rb 14 | 15 | # Requires supporting ruby files with custom matchers and macros, etc, in 16 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 17 | # run as spec files by default. This means that files in spec/support that end 18 | # in _spec.rb will both be required and run as specs, causing the specs to be 19 | # run twice. It is recommended that you do not name files matching this glob to 20 | # end with _spec.rb. You can configure this pattern with the --pattern 21 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 22 | 23 | # The following line is provided for convenience purposes. It has the downside 24 | # of increasing the boot-up time by auto-requiring all files in the support 25 | # directory. Alternatively, in the individual `*_spec.rb` files, manually 26 | # require only the support files necessary. 27 | Dir[Rails.root.join("spec", "support", "**", "*.rb")].sort.each { |f| require f } 28 | 29 | # Checks for pending migrations and applies them before tests are run. 30 | # If you are not using ActiveRecord, you can remove these lines. 31 | begin 32 | ActiveRecord::Migration.maintain_test_schema! 33 | rescue ActiveRecord::PendingMigrationError => e 34 | puts e.to_s.strip 35 | exit 1 36 | end 37 | 38 | RSpec.configure do |config| 39 | # Update to use fixture_paths (plural) for Rails 7.1+ 40 | config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] 41 | 42 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 43 | # examples within a transaction, remove the following line or assign false 44 | # instead of true. 45 | config.use_transactional_fixtures = true 46 | 47 | # FactoryBot configuration 48 | config.include FactoryBot::Syntax::Methods 49 | 50 | # Use database cleaner for cleaning the database 51 | config.before(:suite) do 52 | DatabaseCleaner.strategy = :transaction 53 | DatabaseCleaner.clean_with(:truncation) 54 | end 55 | 56 | config.around(:each) do |example| 57 | DatabaseCleaner.cleaning do 58 | example.run 59 | end 60 | end 61 | 62 | # RSpec Rails can automatically mix in different behaviours to your tests 63 | # based on their file location, for example enabling you to call `get` and 64 | # `post` in specs under `spec/controllers`. 65 | config.infer_spec_type_from_file_location! 66 | 67 | # Filter lines from Rails gems in backtraces. 68 | config.filter_rails_from_backtrace! 69 | end 70 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Require only the support scaffolding necessary. 9 | # 10 | # See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 11 | RSpec.configure do |config| 12 | # rspec-expectations config goes here. 13 | config.expect_with :rspec do |expectations| 14 | # This option will default to `true` in RSpec 4. 15 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 16 | end 17 | 18 | # rspec-mocks config goes here 19 | config.mock_with :rspec do |mocks| 20 | # This option will default to false in RSpec 4. 21 | mocks.verify_partial_doubles = true 22 | end 23 | 24 | # This option will default to `:apply_to_host_groups` in RSpec 4 25 | config.shared_context_metadata_behavior = :apply_to_host_groups 26 | 27 | # Run specs in random order to surface order dependencies 28 | config.order = :random 29 | 30 | # Seed global randomization in this process using the `--seed` CLI option 31 | Kernel.srand config.seed 32 | end 33 | -------------------------------------------------------------------------------- /spec/support/factory_bot.rb: -------------------------------------------------------------------------------- 1 | # RSpec.configure do |config| 2 | # config.include FactoryBot::Syntax::Methods 3 | 4 | # config.before(:suite) do 5 | # FactoryBot.definition_file_paths = [ 6 | # File.join(File.dirname(__FILE__), '../factories') 7 | # ] 8 | # FactoryBot.find_definitions 9 | # end 10 | # end 11 | --------------------------------------------------------------------------------