Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Permissions.pm
Diff
/var/www/vhosts/3dshawn.com/crm.3dshawn.com/MODS/ContactForge/Permissions.pm
added on local at 2026-07-01 15:02:54
Added
+0
lines
Removed
-0
lines
Context
231
unchanged
Blobs
from 6c29d3681bcb
to 6c29d3681bcb
to 6c29d3681bcb
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.| 1 | 1 | package MODS::ContactForge::Permissions; |
| 2 | 2 | #====================================================================== |
| 3 | 3 | # ContactForge - role-based permission checks. |
| 4 | 4 | # |
| 5 | 5 | # Backs Wrapper.pm sidebar filtering and the require_feature() page |
| 6 | 6 | # guards CGIs call before rendering. The model: |
| 7 | 7 | # |
| 8 | 8 | # * users.permission_groups_id IS NULL -> owner / unconstrained. |
| 9 | 9 | # Every feature passes; no DB lookup needed. |
| 10 | 10 | # * users.permission_groups_id IS NOT NULL -> team member. |
| 11 | 11 | # The permission_groups row carries permission_features_ids (CSV |
| 12 | 12 | # of feature ids); only those slugs/ids resolve to true. |
| 13 | 13 | # |
| 14 | 14 | # Cached on $userinfo per-request: features_csv + slug_to_id. That way |
| 15 | 15 | # a page that calls has_feature() ten times only hits the DB once. |
| 16 | 16 | #====================================================================== |
| 17 | 17 | use strict; |
| 18 | 18 | |
| 19 | 19 | my $db; |
| 20 | 20 | if (eval { require MODS::DBConnect; 1 }) { |
| 21 | 21 | $db = MODS::DBConnect->new; |
| 22 | 22 | } |
| 23 | 23 | my $config; |
| 24 | 24 | if (eval { require MODS::Config; 1 }) { |
| 25 | 25 | $config = MODS::Config->new; |
| 26 | 26 | } |
| 27 | 27 | my $DB = $config ? $config->settings('database_name') : ''; |
| 28 | 28 | |
| 29 | 29 | sub new { |
| 30 | 30 | my ($class) = @_; |
| 31 | 31 | return bless({}, $class); |
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | #---------------------------------------------------------------------- |
| 35 | 35 | # Returns 1 if $userinfo has access to the given feature, else 0. |
| 36 | 36 | # $userinfo -- hashref returned by Login::login_verify |
| 37 | 37 | # $slug_or_id -- 'edit_models' or numeric '13' |
| 38 | 38 | # |
| 39 | 39 | # Owners (permission_groups_id NULL) ALWAYS get 1. Unauthenticated |
| 40 | 40 | # callers get 0. |
| 41 | 41 | #---------------------------------------------------------------------- |
| 42 | 42 | sub has_feature { |
| 43 | 43 | my ($self, $userinfo, $slug_or_id) = @_; |
| 44 | 44 | return 0 unless $userinfo && $userinfo->{user_id}; |
| 45 | 45 | |
| 46 | 46 | # Owner / unconstrained |
| 47 | 47 | my $pg = $userinfo->{permission_groups_id}; |
| 48 | 48 | return 1 if !defined($pg) || $pg eq ''; |
| 49 | 49 | |
| 50 | 50 | my ($csv, $slug_map) = $self->_load_cached($userinfo); |
| 51 | 51 | return 0 unless defined $csv; |
| 52 | 52 | |
| 53 | 53 | my $id = ($slug_or_id =~ /^\d+$/) ? $slug_or_id : ($slug_map->{$slug_or_id} || 0); |
| 54 | 54 | return 0 unless $id; |
| 55 | 55 | return (",$csv," =~ m/,$id,/) ? 1 : 0; |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | #---------------------------------------------------------------------- |
| 59 | 59 | # Page-entry guard. If $userinfo lacks $slug_or_id, sends a redirect |
| 60 | 60 | # and exits. Owner accounts always pass through. Admins-impersonating |
| 61 | 61 | # pass through (impersonation gives admins target-user data scope but |
| 62 | 62 | # we don't want them locked out of admin tools mid-impersonation). |
| 63 | 63 | # |
| 64 | 64 | # $redirect_to is optional; defaults to /dashboard.cgi for reps, |
| 65 | 65 | # /admin.cgi for admins. |
| 66 | 66 | #---------------------------------------------------------------------- |
| 67 | 67 | sub require_feature { |
| 68 | 68 | my ($self, $userinfo, $slug_or_id, $redirect_to) = @_; |
| 69 | 69 | |
| 70 | 70 | return if $self->has_feature($userinfo, $slug_or_id); |
| 71 | 71 | |
| 72 | 72 | # Admin in impersonation mode: treat as owner-equivalent. Admins |
| 73 | 73 | # should not get bounced out of admin pages just because they're |
| 74 | 74 | # acting as a team-member-target. |
| 75 | 75 | return if $userinfo && $userinfo->{_impersonating}; |
| 76 | 76 | |
| 77 | 77 | if (!$redirect_to) { |
| 78 | 78 | $redirect_to = ($userinfo && $userinfo->{is_admin}) |
| 79 | 79 | ? '/admin.cgi?denied=' . $slug_or_id |
| 80 | 80 | : '/dashboard.cgi?denied=' . $slug_or_id; |
| 81 | 81 | } |
| 82 | 82 | print "Status: 302 Found\nLocation: $redirect_to\n\n"; |
| 83 | 83 | exit; |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | 86 | #---------------------------------------------------------------------- |
| 87 | 87 | # require_owner($userinfo, [$redirect_to]) |
| 88 | 88 | # |
| 89 | 89 | # Owner-only guard. Blocks team members (rows with non-null |
| 90 | 90 | # owner_user_id) entirely -- no role grants access. Used by billing, |
| 91 | 91 | # team management, plan changes, and any other "principal-only" pages. |
| 92 | 92 | # Admin impersonation passes through, same reasoning as require_feature. |
| 93 | 93 | #---------------------------------------------------------------------- |
| 94 | 94 | sub require_owner { |
| 95 | 95 | my ($self, $userinfo, $redirect_to) = @_; |
| 96 | 96 | return if $userinfo && $userinfo->{_impersonating}; |
| 97 | 97 | return if $userinfo && !$userinfo->{owner_user_id}; |
| 98 | 98 | |
| 99 | 99 | $redirect_to ||= '/dashboard.cgi?denied=owner_only'; |
| 100 | 100 | print "Status: 302 Found\nLocation: $redirect_to\n\n"; |
| 101 | 101 | exit; |
| 102 | 102 | } |
| 103 | 103 | |
| 104 | 104 | #---------------------------------------------------------------------- |
| 105 | 105 | # is_owner($userinfo) — bool. Convenience for templates / CGIs that |
| 106 | 106 | # need to *render* something differently rather than redirect. |
| 107 | 107 | #---------------------------------------------------------------------- |
| 108 | 108 | sub is_owner { |
| 109 | 109 | my ($self, $userinfo) = @_; |
| 110 | 110 | return 0 unless $userinfo; |
| 111 | 111 | return ($userinfo->{owner_user_id}) ? 0 : 1; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | #---------------------------------------------------------------------- |
| 115 | 115 | # is_super_admin($userinfo) — bool. Super-admin is the platform owner |
| 116 | 116 | # (founder seat). Required for /admin_config.cgi which edits Stripe |
| 117 | 117 | # keys, branding, and other platform-wide secrets. is_admin alone is |
| 118 | 118 | # NOT enough -- a staff admin can manage users without holding the |
| 119 | 119 | # keys to the kingdom. |
| 120 | 120 | # |
| 121 | 121 | # Implicit invariant: is_super_admin implies is_admin. We check both |
| 122 | 122 | # because a downgraded super-admin row could otherwise still reach |
| 123 | 123 | # the platform-wide config surface. |
| 124 | 124 | #---------------------------------------------------------------------- |
| 125 | 125 | sub is_super_admin { |
| 126 | 126 | my ($self, $userinfo) = @_; |
| 127 | 127 | return 0 unless $userinfo; |
| 128 | 128 | return 0 unless $userinfo->{is_admin}; |
| 129 | 129 | |
| 130 | 130 | # Cached per request. Login.pm intentionally does NOT include |
| 131 | 131 | # is_super_admin in its SELECT so the login query stays compatible |
| 132 | 132 | # with pre-migration installs. We probe lazily here and stash. |
| 133 | 133 | return $userinfo->{_super_admin} if exists $userinfo->{_super_admin}; |
| 134 | 134 | |
| 135 | 135 | my $uid = $userinfo->{user_id}; |
| 136 | 136 | $uid =~ s/[^0-9]//g if defined $uid; |
| 137 | 137 | return 0 unless $uid; |
| 138 | 138 | return 0 unless $db && $DB; |
| 139 | 139 | |
| 140 | 140 | my $dbh = $db->db_connect(); |
| 141 | 141 | return 0 unless $dbh; |
| 142 | 142 | |
| 143 | 143 | # Guard for the column-not-yet-migrated state so a brand-new |
| 144 | 144 | # install doesn't 500 the admin pages. |
| 145 | 145 | my $col_check = $db->db_readwrite($dbh, qq~ |
| 146 | 146 | SELECT COUNT(*) AS n FROM information_schema.columns |
| 147 | 147 | WHERE table_schema='$DB' AND table_name='users' |
| 148 | 148 | AND column_name='is_super_admin' |
| 149 | 149 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 150 | 150 | unless ($col_check && $col_check->{n}) { |
| 151 | 151 | $db->db_disconnect($dbh); |
| 152 | 152 | $userinfo->{_super_admin} = 0; |
| 153 | 153 | return 0; |
| 154 | 154 | } |
| 155 | 155 | |
| 156 | 156 | my $r = $db->db_readwrite($dbh, qq~ |
| 157 | 157 | SELECT is_super_admin FROM `${DB}`.users WHERE id='$uid' LIMIT 1 |
| 158 | 158 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 159 | 159 | $db->db_disconnect($dbh); |
| 160 | 160 | |
| 161 | 161 | my $flag = ($r && $r->{is_super_admin}) ? 1 : 0; |
| 162 | 162 | $userinfo->{_super_admin} = $flag; |
| 163 | 163 | return $flag; |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | 166 | #---------------------------------------------------------------------- |
| 167 | 167 | # require_super_admin($userinfo, [$redirect_to]) |
| 168 | 168 | # |
| 169 | 169 | # Page-entry guard for the Software Configuration surface and any |
| 170 | 170 | # other "platform owner only" controls. Bounces non-super-admins to |
| 171 | 171 | # /admin.cgi with a denied marker. Impersonation does NOT pass through |
| 172 | 172 | # here -- a regular admin acting as the super-admin still can't edit |
| 173 | 173 | # platform secrets. |
| 174 | 174 | #---------------------------------------------------------------------- |
| 175 | 175 | sub require_super_admin { |
| 176 | 176 | my ($self, $userinfo, $redirect_to) = @_; |
| 177 | 177 | return if $self->is_super_admin($userinfo); |
| 178 | 178 | $redirect_to ||= '/admin.cgi?denied=super_admin'; |
| 179 | 179 | print "Status: 302 Found\nLocation: $redirect_to\n\n"; |
| 180 | 180 | exit; |
| 181 | 181 | } |
| 182 | 182 | |
| 183 | 183 | #---------------------------------------------------------------------- |
| 184 | 184 | # Returns ($csv, $slug_map) for this $userinfo. Hydrates from DB on |
| 185 | 185 | # first call and stashes on $userinfo so subsequent calls don't query. |
| 186 | 186 | # |
| 187 | 187 | # For owners returns (undef, {}). Callers should check the |
| 188 | 188 | # permission_groups_id before calling -- this method assumes a team |
| 189 | 189 | # member context. |
| 190 | 190 | #---------------------------------------------------------------------- |
| 191 | 191 | sub _load_cached { |
| 192 | 192 | my ($self, $userinfo) = @_; |
| 193 | 193 | |
| 194 | 194 | if (exists $userinfo->{_perm_features_csv}) { |
| 195 | 195 | return ($userinfo->{_perm_features_csv}, $userinfo->{_perm_slug_to_id} || {}); |
| 196 | 196 | } |
| 197 | 197 | |
| 198 | 198 | my $pg = $userinfo->{permission_groups_id}; |
| 199 | 199 | return (undef, {}) unless $pg; |
| 200 | 200 | |
| 201 | 201 | my $safe_pg = $pg; |
| 202 | 202 | $safe_pg =~ s/[^0-9]//g; |
| 203 | 203 | return (undef, {}) unless $safe_pg; |
| 204 | 204 | |
| 205 | 205 | my $dbh = $db->db_connect(); |
| 206 | 206 | return (undef, {}) unless $dbh; |
| 207 | 207 | |
| 208 | 208 | # Pull the CSV for this user's group + the full slug->id map in |
| 209 | 209 | # one trip. The slug map is global (not per-user) so caching it on |
| 210 | 210 | # $userinfo is fine -- it's also tiny (28 rows today). |
| 211 | 211 | my $row = $db->db_readwrite($dbh, qq~ |
| 212 | 212 | SELECT permission_features_ids FROM `${DB}`.permission_groups |
| 213 | 213 | WHERE id='$safe_pg' AND group_status=1 LIMIT 1 |
| 214 | 214 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 215 | 215 | my $csv = ($row && defined $row->{permission_features_ids}) |
| 216 | 216 | ? $row->{permission_features_ids} : ''; |
| 217 | 217 | |
| 218 | 218 | my @rows = $db->db_readwrite_multiple($dbh, qq~ |
| 219 | 219 | SELECT id, slug FROM `${DB}`.permission_features |
| 220 | 220 | ~, $ENV{SCRIPT_NAME}, __LINE__); |
| 221 | 221 | my %map; |
| 222 | 222 | foreach my $r (@rows) { $map{$r->{slug}} = $r->{id}; } |
| 223 | 223 | |
| 224 | 224 | $db->db_disconnect($dbh); |
| 225 | 225 | |
| 226 | 226 | $userinfo->{_perm_features_csv} = $csv; |
| 227 | 227 | $userinfo->{_perm_slug_to_id} = \%map; |
| 228 | 228 | return ($csv, \%map); |
| 229 | 229 | } |
| 230 | 230 | |
| 231 | 231 | 1; |