Add fine-grained permission control for extended channel permissions (#35)

This commit is contained in:
Knut Ahlers 2022-12-04 17:14:15 +01:00 committed by GitHub
parent 4b9878fbf9
commit a06d8fa1cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 195 additions and 107 deletions

View file

@ -17,10 +17,10 @@ import (
type (
configEditorGeneralConfig struct {
BotEditors []string `json:"bot_editors"`
BotName *string `json:"bot_name,omitempty"`
Channels []string `json:"channels"`
ChannelHasScopes map[string]bool `json:"channel_has_scopes"`
BotEditors []string `json:"bot_editors"`
BotName *string `json:"bot_name,omitempty"`
Channels []string `json:"channels"`
ChannelScopes map[string][]string `json:"channel_scopes"`
}
)
@ -135,10 +135,13 @@ func configEditorHandleGeneralAddAuthToken(w http.ResponseWriter, r *http.Reques
func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
var out struct {
UpdateBotToken string `json:"update_bot_token"`
UpdateChannelScopes string `json:"update_channel_scopes"`
AvailableExtendedScopes map[string]string `json:"available_extended_scopes"`
UpdateBotToken string `json:"update_bot_token"`
UpdateChannelScopes string `json:"update_channel_scopes"`
}
out.AvailableExtendedScopes = channelExtendedScopes
params := make(url.Values)
params.Set("client_id", cfg.TwitchClient)
params.Set("redirect_uri", strings.Join([]string{
@ -155,7 +158,7 @@ func configEditorHandleGeneralAuthURLs(w http.ResponseWriter, r *http.Request) {
strings.TrimRight(cfg.BaseURL, "/"),
"auth", "update-channel-scopes",
}, "/"))
params.Set("scope", strings.Join(channelDefaultScopes, " "))
params.Set("scope", "")
out.UpdateChannelScopes = fmt.Sprintf("https://id.twitch.tv/oauth2/authorize?%s", params.Encode())
@ -184,12 +187,12 @@ func configEditorHandleGeneralDeleteAuthToken(w http.ResponseWriter, r *http.Req
func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
var (
elevated = make(map[string]bool)
err error
channelScopes = make(map[string][]string)
err error
)
for _, ch := range config.Channels {
if elevated[ch], err = accessService.HasPermissionsForChannel(ch, channelDefaultScopes...); err != nil {
if channelScopes[ch], err = accessService.GetChannelPermissions(ch); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@ -201,10 +204,10 @@ func configEditorHandleGeneralGet(w http.ResponseWriter, r *http.Request) {
}
if err := json.NewEncoder(w).Encode(configEditorGeneralConfig{
BotEditors: config.BotEditors,
BotName: uName,
Channels: config.Channels,
ChannelHasScopes: elevated,
BotEditors: config.BotEditors,
BotName: uName,
Channels: config.Channels,
ChannelScopes: channelScopes,
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

View file

@ -44,6 +44,22 @@ func New(db database.Connector) (*Service, error) {
)
}
func (s Service) GetChannelPermissions(channel string) ([]string, error) {
var (
err error
perm extendedPermission
)
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, errors.Wrap(err, "getting twitch credential from database")
}
return strings.Split(perm.Scopes, " "), nil
}
func (s Service) GetBotTwitchClient(cfg ClientConfig) (*twitch.Client, error) {
var botAccessToken, botRefreshToken string
@ -98,20 +114,11 @@ func (s Service) GetTwitchClientForChannel(channel string, cfg ClientConfig) (*t
}
func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (bool, error) {
var (
err error
perm extendedPermission
)
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, errors.Wrap(err, "getting twitch credential from database")
storedScopes, err := s.GetChannelPermissions(channel)
if err != nil {
return false, errors.Wrap(err, "getting channel scopes")
}
storedScopes := strings.Split(perm.Scopes, " ")
for _, scope := range scopes {
if str.StringInSlice(scope, storedScopes) {
return true, nil
@ -122,20 +129,11 @@ func (s Service) HasAnyPermissionForChannel(channel string, scopes ...string) (b
}
func (s Service) HasPermissionsForChannel(channel string, scopes ...string) (bool, error) {
var (
err error
perm extendedPermission
)
if err = s.db.DB().First(&perm, "channel = ?", channel).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, errors.Wrap(err, "getting twitch credential from database")
storedScopes, err := s.GetChannelPermissions(channel)
if err != nil {
return false, errors.Wrap(err, "getting channel scopes")
}
storedScopes := strings.Split(perm.Scopes, " ")
for _, scope := range scopes {
if !str.StringInSlice(scope, storedScopes) {
return false, nil

View file

@ -3,20 +3,26 @@ package main
import "github.com/Luzifer/twitch-bot/v3/pkg/twitch"
var (
channelDefaultScopes = []string{
twitch.ScopeChannelEditCommercial,
twitch.ScopeChannelManageBroadcast,
twitch.ScopeChannelReadRedemptions,
twitch.ScopeChannelManageRaids,
channelExtendedScopes = map[string]string{
twitch.ScopeChannelEditCommercial: "run commercial",
twitch.ScopeChannelManageBroadcast: "modify category / title",
twitch.ScopeChannelManagePolls: "manage polls",
twitch.ScopeChannelManagePredictions: "manage predictions",
twitch.ScopeChannelManageRaids: "start raids",
twitch.ScopeChannelManageVIPS: "manage VIPs",
twitch.ScopeChannelReadRedemptions: "see channel-point redemptions",
}
botDefaultScopes = append(channelDefaultScopes,
twitch.ScopeChatEdit,
twitch.ScopeChatRead,
botDefaultScopes = []string{
// API Scopes
twitch.ScopeModeratorManageAnnoucements,
twitch.ScopeModeratorManageBannedUsers,
twitch.ScopeModeratorManageChatMessages,
twitch.ScopeModeratorManageChatSettings,
// Chat Scopes
twitch.ScopeChatEdit,
twitch.ScopeChatRead,
twitch.ScopeWhisperRead,
)
}
)

View file

@ -25,7 +25,7 @@
{{ channel }}
<span class="ml-auto mr-2">
<font-awesome-icon
v-if="!generalConfig.channel_has_scopes[channel]"
v-if="!hasAllExtendedScopes(channel)"
:id="`channelPublicWarn${channel}`"
fixed-width
class="ml-1 text-warning"
@ -35,20 +35,30 @@
:target="`channelPublicWarn${channel}`"
triggers="hover"
>
Channel cannot use features like channel-point redemptions.
See "Channel Permissions" for more info how to authorize.
Channel is missing {{ missingExtendedScopes(channel).length }} extended permissions.
Click pencil to change granted permissions.
</b-tooltip>
</span>
<b-button
size="sm"
variant="danger"
@click="removeChannel(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
<b-button-group size="sm">
<b-button
variant="primary"
@click="editChannelPermissions(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'pencil-alt']"
/>
</b-button>
<b-button
variant="danger"
@click="removeChannel(channel)"
>
<font-awesome-icon
fixed-width
:icon="['fas', 'minus']"
/>
</b-button>
</b-button-group>
</b-list-group-item>
<b-list-group-item>
@ -281,52 +291,6 @@
</b-input-group>
</b-card-body>
</b-card>
<b-card
no-body
class="mb-3"
>
<b-card-header>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'sign-in-alt']"
/>
Channel Permissions
</b-card-header>
<b-card-body>
<p>
In order to access non-public information as channel-point redemptions the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
</p>
<ul>
<li>Copy the URL provided below</li>
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
<li>The bot will display a message containing the updated account</li>
</ul>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="authURLs.update_channel_scopes"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.channelPermission"
@click="copyAuthURL('channelPermission')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-card-body>
</b-card>
</b-col>
</b-row>
@ -366,6 +330,70 @@
/>
</b-form-group>
</b-modal>
<!-- Channel Permission Editor -->
<b-modal
v-if="showPermissionEditModal"
hide-footer
size="lg"
title="Edit Permissions for Channel"
:visible="showPermissionEditModal"
@hidden="showPermissionEditModal=false"
>
<b-row>
<b-col>
<p>The bot should be able to&hellip;</p>
<b-form-checkbox-group
id="channelPermissions"
v-model="models.channelPermissions"
:options="extendedPermissions"
multiple
:select-size="extendedPermissions.length"
stacked
switches
/>
<p class="mt-3">
&hellip;on this channel.
</p>
</b-col>
<b-col>
<p>
In order to access non-public information as channel-point redemptions or take actions limited to the channel owner the bot needs additional permissions. The <strong>owner</strong> of the channel needs to grant those!
</p>
<ul>
<li>Select permissions on the left side</li>
<li>Copy the URL provided below</li>
<li>Pass the URL to the channel owner and tell them to open it with their personal account logged in</li>
<li>The bot will display a message containing the updated account</li>
</ul>
</b-col>
</b-row>
<b-row>
<b-col>
<b-input-group>
<b-form-input
placeholder="Loading..."
readonly
:value="extendedPermissionsURL"
@focus="$event.target.select()"
/>
<b-input-group-append>
<b-button
:variant="copyButtonVariant.channelPermission"
@click="copyAuthURL('channelPermission')"
>
<font-awesome-icon
fixed-width
class="mr-1"
:icon="['fas', 'clipboard']"
/>
Copy
</b-button>
</b-input-group-append>
</b-input-group>
</b-col>
</b-row>
</b-modal>
</div>
</template>
@ -393,6 +421,22 @@ export default {
return 'warning'
},
extendedPermissions() {
return Object.keys(this.authURLs.available_extended_scopes || {})
.map(v => ({ text: this.authURLs.available_extended_scopes[v], value: v }))
.sort((a, b) => a.value.localeCompare(b.value))
},
extendedPermissionsURL() {
if (!this.authURLs?.update_channel_scopes) {
return null
}
const u = new URL(this.authURLs.update_channel_scopes)
u.searchParams.set('scope', this.models.channelPermissions.join(' '))
return u.toString()
},
sortedChannels() {
return [...this.generalConfig?.channels || []].sort((a, b) => a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()))
},
@ -426,11 +470,13 @@ export default {
addChannel: '',
addEditor: '',
apiToken: {},
channelPermissions: [],
},
modules: [],
showAPITokenEditModal: false,
showPermissionEditModal: false,
userProfiles: {},
}
},
@ -469,7 +515,7 @@ export default {
btnField = 'botConnection'
break
case 'channelPermission':
prom = navigator.clipboard.writeText(this.authURLs.update_channel_scopes)
prom = navigator.clipboard.writeText(this.extendedPermissionsURL)
btnField = 'channelPermission'
break
}
@ -488,6 +534,11 @@ export default {
})
},
editChannelPermissions(channel) {
this.models.channelPermissions = this.generalConfig.channel_scopes[channel] || []
this.showPermissionEditModal = true
},
fetchAPITokens() {
this.$bus.$emit(constants.NOTIFY_LOADING_DATA, true)
return axios.get('config-editor/auth-tokens', this.$root.axiosOptions)
@ -541,6 +592,36 @@ export default {
.catch(err => this.$bus.$emit(constants.NOTIFY_FETCH_ERROR, err))
},
hasAllExtendedScopes(channel) {
if (!this.generalConfig.channel_scopes[channel]) {
return false
}
for (const scope in this.authURLs.available_extended_scopes) {
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
return false
}
}
return true
},
missingExtendedScopes(channel) {
if (!this.generalConfig.channel_scopes[channel]) {
return Object.keys(this.authURLs.available_extended_scopes)
}
const missing = []
for (const scope in this.authURLs.available_extended_scopes) {
if (!this.generalConfig.channel_scopes[channel].includes(scope)) {
missing.push(scope)
}
}
return missing
},
newAPIToken() {
Vue.set(this.models, 'apiToken', {
modules: [],